A tiny, self-hosted CI/CD daemon — turns a GitHub push into a deploy.sh
run on your server, with Discord notifications and structured logs.
- One Go binary, no runtime dependencies.
- HMAC-verified GitHub webhooks (
X-Hub-Signature-256). - Built-in
git fetch + reset --hard <sha>before invoking your script, so deploy scripts never have to handle their own clone logic. - Per-project mutex: pushes to the same project queue serially; pushes to different projects run in parallel.
- Discord embed for each deploy: blurple while running, green on success, red on failure, with the last 1.4 KB of the deploy log inline.
Designed to scale from one repo to dozens; one process, one config file, one webhook per repo.
- How it works
- Quick start
- Configuration
- Adding a new project
- Self-hosting checklist
- Endpoints
- Security notes
- Development
GitHub repo ──push──► https://<your-host>/webhook/<project>
│
▼
HMAC-verify body against WEBHOOK_SECRET
│
▼
git fetch --prune && git reset --hard <sha>
│
▼
bash <project.dir>/<deploy_script> ──── stdout/stderr ─► /var/log/git-deployer/<project>-<ts>.log
│
▼
Discord webhook
(running → ok / failed)
The handler:
- Validates the
X-Hub-Signature-256HMAC. - Returns
202 Acceptedimmediately. - Spawns a goroutine that:
- Acquires the project mutex.
- Posts a "🚀 Deploying" embed to Discord (blurple).
- Runs
git fetch + reset --hard <sha>inside the project's working directory (skippable withskip_fetch: true). - Execs
bash <deploy_script>withcwd = project.dir. - Captures full stdout/stderr to a log file plus tail.
- Posts a "✅ Deployed" or "❌ Deploy failed" embed.
Only push events to the configured branch trigger a deploy.
ping returns 200. Any other event is acked and ignored.
The fastest path: standalone Linux box, single project, fronted by nginx.
# 1. Build the binary
go install github.com/mrvcoder/git-deployer/cmd/git-deployer@latest
sudo install -m 0755 "$(go env GOPATH)/bin/git-deployer" /usr/local/bin/git-deployer
# 2. Create config dirs
sudo mkdir -p /etc/git-deployer /var/log/git-deployer
# 3. Drop in your config.json (see below)
sudo cp config.example.json /etc/git-deployer/config.json
sudo $EDITOR /etc/git-deployer/config.json
# 4. Drop in your .env with secrets
sudo cp .env.example /etc/git-deployer/.env
sudo chmod 600 /etc/git-deployer/.env
sudo $EDITOR /etc/git-deployer/.env
# set WEBHOOK_SECRET to a long random string
# set GITHUB_TOKEN if any project is a private repo
# set DISCORD_WEBHOOK_URL if you want notifications
# 5. Install + start the systemd unit
sudo cp deploy/systemd/git-deployer.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now git-deployer
sudo systemctl status git-deployer
# 6. Front it with nginx (or Caddy / Traefik / whatever)
sudo cp deploy/nginx/git-deployer.conf /etc/nginx/conf.d/
sudo sed -i 's/git\.example\.com/<your-real-host>/' /etc/nginx/conf.d/git-deployer.conf
sudo nginx -t && sudo systemctl reload nginxNow point a GitHub webhook at https://<your-host>/webhook/<project-name>
(see Adding a new project).
git-deployer reads runtime state from two files:
/etc/git-deployer/config.json— the project list. Public-safe; can be committed./etc/git-deployer/.env— secrets (WEBHOOK_SECRET,GITHUB_TOKEN,DISCORD_WEBHOOK_URL).0600, never commit.
Both paths are overridable with -config / -env flags.
{
"projects": [
{
"name": "my-app",
"repo": "your-org/my-app",
"branch": "main",
"dir": "/srv/my-app",
"deploy_script": "deploy.sh",
"skip_fetch": false
}
]
}| Field | Required | Default | Notes |
|---|---|---|---|
name |
yes | — | URL slug for the webhook (/webhook/<name>). Also the project mutex key. |
repo |
yes | — | owner/name on GitHub. Used to build the clone URL and link to commits in Discord. |
branch |
no | main |
Only pushes to this ref fire a deploy. |
dir |
yes | — | Absolute path on disk. The deploy script runs here. |
deploy_script |
no | deploy.sh |
File inside dir to exec. |
skip_fetch |
no | false |
Set true if your deploy.sh handles its own clone/checkout. Otherwise the deployer does it. |
WEBHOOK_SECRET=replace-with-a-long-random-string
DISCORD_WEBHOOK_URL=
GITHUB_TOKEN=
DEPLOYER_HOST=
LISTEN_ADDR=127.0.0.1:8081
LOG_DIR=/var/log/git-deployer
| Variable | Required | Default | Notes |
|---|---|---|---|
WEBHOOK_SECRET |
yes | — | Shared secret with GitHub. Generate: openssl rand -hex 32. |
DISCORD_WEBHOOK_URL |
no | (disabled) | Set to a Discord channel webhook URL to receive embeds. Empty = no notifications. |
GITHUB_TOKEN |
no | (public-only) | PAT (fine-grained or classic). Required for private-repo clones. Exposed as $GITHUB_TOKEN to deploy.sh. |
DEPLOYER_HOST |
no | OS hostname | Brand shown on / and in Discord footers (e.g. git.example.com). |
LISTEN_ADDR |
no | 127.0.0.1:8081 |
Bind address. Front it with nginx / Caddy. |
LOG_DIR |
no | /var/log/git-deployer |
One file per deploy run: <project>-<UTC-timestamp>.log. |
Lives at <project.dir>/<deploy_script>. Executed with cwd = project.dir.
The deployer exports:
| Var | Meaning |
|---|---|
GIT_REPO |
<owner>/<name> — matches config.json |
GIT_BRANCH |
branch from config |
GIT_REF |
refs/heads/<branch> |
GIT_SHA |
commit being deployed (payload.after) |
GIT_PUSHER |
GitHub username that pushed |
GITHUB_TOKEN |
the PAT from .env, if set — useful for npm install of private packages, etc. |
By default git fetch + reset --hard $GIT_SHA && git clean -fd runs
before deploy.sh, so when your script starts the working tree
already matches the pushed commit. The remote URL is reset to the plain
(no-token) HTTPS form afterwards so the PAT is never persisted on disk.
Exit 0 → green Discord embed. Anything else → red.
Minimal deploy.sh for a Node app:
#!/usr/bin/env bash
set -euo pipefail
echo "[deploy] building ${GIT_SHA:-HEAD}"
npm ci --no-audit --no-fund
npm run build
pm2 reload my-app --update-env-
Drop a
deploy.shat the repo root. Make it executable in repo, or invoke it viabash; the deployer usesbash <script>. -
Make sure the project dir exists on the server:
sudo mkdir -p /srv/my-app sudo chown <runtime-user>:<runtime-user> /srv/my-app # if not running as root
-
Add an entry to
/etc/git-deployer/config.jsonand reload:sudo systemctl restart git-deployer
-
Create the GitHub webhook — repository → Settings → Webhooks → Add webhook:
- Payload URL:
https://<your-host>/webhook/<project-name> - Content type:
application/json - Secret: the same value as
WEBHOOK_SECRETin/etc/git-deployer/.env - SSL verification: Enable
- Events: "Just the push event"
- Payload URL:
-
Push a commit and watch:
journalctl -u git-deployer -ffor service logstail -f /var/log/git-deployer/<project>-*.logfor the deploy stdout- Your Discord channel for the embed
If the first push fails:
403/bad signature→ secret mismatch between GitHub and.env404 unknown project→nameinconfig.jsondoesn't match the URL slug- deploy script
exit 1→ check the linked log file in the Discord embed footer
-
git-deployerrunning under systemd (systemctl is-active git-deployer) - HTTPS-fronted by nginx / Caddy / Traefik
-
/etc/git-deployer/.envmode0600, owned by service user -
LISTEN_ADDR=127.0.0.1:8081(don't expose the Go HTTP port directly) -
WEBHOOK_SECRETis high-entropy random (≥32 hex chars) - Service user can
git,bash, and run whatever your deploy scripts need - Log dir on a partition that won't fill up (or set up logrotate on
/var/log/git-deployer/*.log) - Each project's GitHub webhook uses the same secret as
.env
A simple logrotate snippet:
/var/log/git-deployer/*.log {
weekly
rotate 8
compress
missingok
notifempty
copytruncate
}
| Method | Path | Behavior |
|---|---|---|
| GET | / |
Plain-text status + project list. |
| GET | /health |
Returns ok (200). Use for nginx upstream / monitoring. |
| POST | /webhook/<project> |
GitHub push webhook. HMAC-verified. Returns 202 and runs in goroutine. |
Webhook behaviour:
X-GitHub-Event: ping→200 {"ok":true,"pong":<delivery-id>}.X-GitHub-Event: push, branch matches →202 {"ok":true,...}, deploy fires asynchronously.X-GitHub-Event: push, branch mismatch →200 {"skipped":"ref mismatch",...}.- Any other event →
202 {"ok":true,"skipped":"<event>"}. - Bad HMAC →
401 bad signature(logged with remote IP). - Unknown project name →
404 unknown project "...".
- HMAC verification is mandatory. The handler rejects any POST whose
X-Hub-Signature-256doesn't matchhmac_sha256(WEBHOOK_SECRET, raw_body). - The PAT is never persisted in
remote.origin.url. The deployer uses a one-shotx-access-token:<pat>@github.com/...URL on thegit fetchcommand only; afterwards it resetsoriginto the plain HTTPS URL. - Deploy logs may contain secrets if your
deploy.shechoes them. Treat/var/log/git-deployer/as sensitive. deploy.shruns as the service user (defaultrootin the shipped unit file). Run as a less-privileged user if your scripts don't need root.- Body size is capped at 1 MiB. GitHub push payloads are well below this; the cap blocks accidental DoS via large bogus POSTs.
- The legacy GitHub
X-Hub-Signature(sha1) is not accepted — onlyX-Hub-Signature-256.
git clone https://github.com/mrvcoder/git-deployer
cd git-deployer
go build ./cmd/git-deployer
# Run with example config + your own .env
./git-deployer -config ./config.example.json -env ./.envRepository layout:
cmd/git-deployer/main.go # binary entrypoint
internal/config/ # config.json + .env loader
internal/server/ # http handlers, HMAC verify, webhook router
internal/deploy/ # per-project runner, git sync, log tailer
internal/discord/ # webhook embed sender
deploy/systemd/ # example systemd unit
deploy/nginx/ # example nginx vhost
deploy.sh # self-deploy script for git-deployer itself
config.example.json
.env.example
The webhook listener is in internal/server/server.go —
HMAC verification, push payload parsing, branch matching, and the
project lookup happen there. The actual git sync and script exec live
in internal/deploy/deploy.go. Both files
are small (~250 lines each) and self-contained.
PRs welcome.
MIT — see LICENSE.