A small Fiber v2 server that receives GitHub push
webhooks, verifies the signature, then runs a deploy script in the background.
Supports multiple projects at once (one server, separate secret/script/branch
per project), plus Discord notifications and error logs. A replacement for
cloud GitHub Actions runners β everything runs on your own server, so it's free.
cmd/server/main.go entry point β wires every layer (manual dependency injection)
internal/
βββ config/ reads & validates environment variables, including the project list
βββ model/ data structs (DeployRecord, Project, GitHubPushPayload)
βββ repository/ data access layer β interface + JSON file implementation
β βββ deploy_repository.go
βββ service/
β βββ github_service.go HMAC signature verification + GitHub payload parsing
β βββ deploy_service.go runs the project script, records the result, sends notifications
β βββ notifier.go posts to a Discord webhook + writes error logs to file
βββ controller/ HTTP layer β takes Fiber requests, calls services, returns JSON
β βββ webhook_controller.go
βββ middleware/ custom request logger
βββ router/ route -> handler registration
deploys/ per-project deploy scripts (one file per project)
βββ _examples/ ready-to-use examples: laravel, node, python, docker
Dependency flow: controller β service β repository. Each layer only
knows the interface of the layer below it, never the concrete implementation.
Swapping storage to SQLite/Postgres later just means writing a new
implementation of repository.DeployRepository β service and controller
stay untouched.
A single instance can handle many projects/repos, each with its own secret,
deploy script, and target branch. Configured via the PROJECTS env var
(comma-separated slugs) plus per-project <SLUG>_WEBHOOK_SECRET,
<SLUG>_DEPLOY_SCRIPT, <SLUG>_ALLOWED_BRANCH β see Configuration.
When a webhook arrives, the server tries the signature against each project
until one matches β so the single /github endpoint can be registered on many
GitHub repos at once, just with a different secret per repo.
- Push to a branch β GitHub sends
POST /githubwith theX-Hub-Signature-256andX-GitHub-Eventheaders. WebhookController.HandleGitHubWebhooktakes the raw body and matches the signature (HMAC-SHA256,hmac.Equalto stay safe from timing attacks) against every registered project until one matches.- If valid and the branch matches (
<SLUG>_ALLOWED_BRANCH), the controller callsDeployService.TriggerDeployβ which returns adeploy_idimmediately while the script itself runs in a separate goroutine. This matters because GitHub webhook delivery times out after ~10 seconds, while a build/deploy can take much longer. - The goroutine runs the project script (10 minute timeout), captures
stdout+stderr, updates the record status (
runningβsuccess/failed) throughDeployRepository.Update, then sends a Discord notification. On failure the error is also written to a log file inERROR_LOG_DIR. - Check the result via
GET /deploys.
| Method | Path | Purpose |
|---|---|---|
| POST | /github |
Receive push webhooks from GitHub |
| GET | /deploys?limit=N |
Deploy history across all projects (newest first) |
| GET | /health |
Health check |
scp -r webhook-deploy/ deploy@server.local:/home/deploy/
ssh deploy@server.local
cd /home/deploy/webhook-deploy
sudo apt install golang-go
go mod tidy
go build -o webhook-deploy-bin ./cmd/serverEach project needs its own deploy script, looked up by default at
./deploys/<slug>.deploy.sh (override with <SLUG>_DEPLOY_SCRIPT).
Ready-to-use examples live in deploys/_examples/ β
Laravel, Node, Python, Docker. Copy one and adjust its paths/service names:
cp deploys/_examples/laravel.deploy.sh deploys/myapp.deploy.sh
chmod +x deploys/myapp.deploy.shIf the script needs sudo (e.g. systemctl restart / supervisorctl restart),
grant passwordless access to that specific command only β not full sudo:
sudo visudo -f /etc/sudoers.d/webhook-deploy
# contents:
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart your-appEdit them directly in webhook-deploy.service (the Environment= lines) β see
Configuration for the full list β then:
sudo cp webhook-deploy.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now webhook-deploy
sudo systemctl status webhook-deployImportant: every <SLUG>_WEBHOOK_SECRET must be a long random string, not a
guessable password β generate one with openssl rand -hex 32.
GitHub needs to reach your server. Pick one:
- A domain/subdomain + reverse proxy (nginx/Caddy) + port forwarding on your router.
- Cloudflare Tunnel β easiest for a home server, no router ports to open.
Repo β Settings β Webhooks β Add webhook
- Payload URL:
https://your-domain.com/github - Content type:
application/json - Secret: exactly the same as that project's
<SLUG>_WEBHOOK_SECRET - Events: choose Just the push event
GitHub sends a ping event right away β the server replies
{"message":"pong","project":"<slug>"} automatically (see
HandleGitHubWebhook, the X-GitHub-Event check).
| Env var | Required | Default | Purpose |
|---|---|---|---|
PORT |
- | 9000 |
HTTP server port |
PROJECTS |
β | kuroneko |
Comma-separated project slugs, e.g. PROJECTS=api,web |
<SLUG>_WEBHOOK_SECRET |
β (per project) | - | HMAC secret used to verify the GitHub signature |
<SLUG>_DEPLOY_SCRIPT |
- | ./deploys/<slug>.deploy.sh |
Path to that project's deploy script |
<SLUG>_ALLOWED_BRANCH |
- | refs/heads/main |
Branch that triggers a deploy |
DEPLOY_HISTORY_PATH |
- | ./deploy_history.json |
File where deploy history is stored |
DISCORD_WEBHOOK_URL |
- | - | If set, sends success/failure notifications to Discord |
ERROR_LOG_DIR |
- | ./logs |
Directory for error logs written on failed deploys |
<SLUG> = the project name from PROJECTS, uppercased with - replaced by _
(e.g. my-app β MY_APP_WEBHOOK_SECRET).
export PROJECTS=myapp
export MYAPP_WEBHOOK_SECRET=testsecret123
export MYAPP_DEPLOY_SCRIPT=./deploys/myapp.deploy.sh
./webhook-deploy-binIn another terminal, simulate a push event:
PAYLOAD='{"ref":"refs/heads/main","after":"abc123","pusher":{"name":"you"},"repository":{"full_name":"you/repo"}}'
SECRET="testsecret123"
SIG="sha256=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')"
curl -X POST http://localhost:9000/github \
-H "X-GitHub-Event: push" \
-H "X-Hub-Signature-256: $SIG" \
-d "$PAYLOAD"
curl http://localhost:9000/deploys- Raw body for HMAC:
c.Body()is used as-is, not re-parsed JSON, because GitHub computes the signature over the exact bytes it sent β even a slight reserialization breaks the match. - Signature matched against every project: rather than guessing the project
from a URL/path, the server runs
hmac.Equalagainst each registered project's secret. Simple, safe from timing attacks, and one/githubendpoint covers every repo. - Async deploy (goroutine): keeps the response to GitHub fast so a slow deploy script doesn't cause a timeout/retry storm.
- Repository pattern over a JSON file: plenty for a single-node webhook
server. If it ever isn't, swap in a SQLite implementation of
DeployRepositorywithout touchingservice/controller. - Notifier kept out of the deploy logic:
notifier.Discordandnotifier.AppendErrorare plain functions, not interfaces β called directly fromdeployService, no abstraction needed beyond that. - No
.envlibrary: Fiber and uuid are the only external dependencies. The systemd unit sets the environment variables, following normal Linux service convention.