my-cd-webhook

Fleet-wide continuous deployment built on adnanh/webhook, replacing the per-project deploy scripts and hand-rolled webhook receivers that used to live inside each application repository.

This repository is checked out on every deploying host (currently s4 and s5) and updated independently of the projects it deploys.

Why this exists

Every project had grown its own deployment mechanism, and each one was a slightly different 200-line bash script plus, in some cases, a bespoke Python HTTP server. They shared no code, drifted apart, and could only be changed by committing to the application repository and redeploying it — which is exactly the thing that is hardest to do when deployment is broken.

Concretely, the patterns this replaces:

Project What was there Problem
webdev/paradicsomleves scripts/webhook.py (275 lines), webhook-install.sh, webhook-uninstall.sh A whole HTTP server, HMAC implementation, systemd watchdog integration and unit generator, maintained inside an image-gallery app. Removed in 660a309.
webdev/domaindingo scripts/deploy-{dev,test,prod}.sh Three near-identical scripts, run by hand. A hostname != s2 guard that exit 0s on mismatch — so a wrong-host run reports success — and which is now simply wrong, because test and prod both moved to s5. deploy-prod.sh has no host guard at all.
webdev/grindex nothing The pipeline stops at "image pushed"; deployment is a human running docker compose up -d on s4.

The webhook was never the problem. A self-contained webhook, reimplemented per project, was.

Design

One manifest. targets.json maps (repo, branch) onto a host, an environment, a compose stack and an image. It is the only file that knows this, and adding an environment is an edit here — never a change to an application repository.

Host-aware by construction. Every host runs the same daemon from the same checkout and acts only on the targets whose host matches. A push for another host's target is a logged no-op, never a silent success. Host matching accepts both s5 and s5.fisher.hu, because the fleet uses both.

Deploys are immutable and provenance-checked. The receiver deploys the <branch>-sha-<short7> tag, verifies the image's org.opencontainers.image.revision label equals the commit that triggered it, then re-pins to the resolved digest before handing it to compose. Mutable tags have already drifted once in this fleet and served a broken build for months.

It never touches git. The compose stacks live in sysadmin/uas-ng and are refreshed by that repository's own updater timer. A deploy script that also runs git reset --hard is doing two jobs badly.

It never runs docker compose down. up -d recreates exactly the services whose image changed. The old test script used down -v, which destroys data volumes.

Secrets stay on the host. secrets.env (mode 0600) holds one webhook secret per repository. The rendered hooks file is generated on the host and is never committed.

Layout

targets.json              the manifest — the single source of truth
bin/cd-target             manifest queries (resolve / list / host-config)
bin/cd-deploy             the one generic deploy script
bin/cd-render-hooks       targets.json -> adnanh/webhook hooks.json for this host
bin/cd-status             what is this host deploying, and is it healthy
etc/cd-webhook.service    systemd user unit template
install/install.sh        idempotent installer
install/uninstall.sh      removal (--purge also drops secrets and logs)
secrets.env.example       template for the host-local secrets file

How a deploy runs

git push origin test
    │
    ▼
Gitea push webhook ──► http://10.255.255.1:21600/hooks/webdev-domaindingo
    │                  (WireGuard only; never exposed publicly)
    ▼
adnanh/webhook       verify HMAC-SHA256, require X-Gitea-Event: push,
                     require a ref we actually deploy
    │
    ▼
bin/cd-deploy --repo webdev/domaindingo --ref refs/heads/test --sha <40-hex>
    │
    ├─ resolve (repo, branch, this host) in targets.json ──► no match? log, exit 0
    ├─ take the per-target lock
    ├─ poll the registry for <branch>-sha-<short7>   (CI is still building)
    ├─ verify the revision label matches the pushed commit
    ├─ resolve tag -> digest, and deploy the digest
    ├─ docker compose up -d
    ├─ health check
    └─ roll back to the previous digest if health fails
                     │
                     ▼
                   ntfy

Installing on a host

Requires adnanh/webhook on PATH (or WEBHOOK_BIN set), plus docker, python3, curl and flock.

git clone ssh://git@gitea.fisher.hu:2221/fisher/my-cd-webhook.git ~/S/my-cd-webhook
cd ~/S/my-cd-webhook
./install/install.sh          # creates the secrets template on the first run
$EDITOR ~/.config/cd-webhook/secrets.env
./install/install.sh          # renders hooks, installs and starts the service

The installer is idempotent — re-run it after editing targets.json, rotating a secret, or pulling a new version of this repository.

It prints the exact webhook URL for each repository at the end. In Gitea, under Repository → Settings → Webhooks → Add Webhook → Gitea:

Field Value
Target URL http://<host wg ip>:21600/hooks/<id>
HTTP Method POST
POST Content Type application/json
Secret the matching value from secrets.env
Trigger On Push Events
Branch filter * (branch selection lives in targets.json)

Adding a target

Add an object to targets.json and re-run install/install.sh on the owning host. Nothing else. If the repository is new to that host, add its secret to secrets.env first and configure the webhook in Gitea.

Fields: name, enabled, repo, branch, env, host, stack_dir, compose_file, compose_project, container, image_repo, image_tag_template, image_env_var, pull_policy_env_var, health_url, health_expect_key, health_expect_value, rollback_on_failure.

image_tag_template understands {branch}, {env}, {sha}, {short7} and {short12}.

Current targets

Target Repo Branch Host Route
domaindingo-test webdev/domaindingo test s5 ddt.fisher.hu
domaindingo-prod webdev/domaindingo prod s5 dd.fisher.hu

Testing without deploying

./bin/cd-status                                   # health of this host's receiver
./bin/cd-target list --host s5.fisher.hu          # what s5 would deploy
./bin/cd-render-hooks --host s5.fisher.hu --redact # the hooks file, secrets masked
./bin/cd-deploy --repo webdev/domaindingo --ref refs/heads/test \
                --sha $(git rev-parse HEAD) --dry-run

CD_TARGETS_FILE points the tooling at a scratch manifest for testing. It changes which manifest is read, never which host this machine may act as.

Documentation

This README covers the design. The rest lives where the fleet's other documentation does:

Where What
docs/OPERATIONS.md runbook — install, deploy by hand, roll back, rotate a secret, troubleshoot
docs/hints.md non-obvious gotchas in this codebase
docs/active-context.md routing index; read it before opening the rest
Vault: Home IT/Services/CD webhook (fleet deployment) operational view — targets, networking, status
Vault: Development/CD webhook development why it exists, how to add a project
Vault: Development/DomainDingo Development what a correct domaindingo deploy is; this repo automates it

Not yet done

  • Port 21600 is proposed, not allocated. The Port registry's rule is to claim a block only once the service is deployed and listening, as part of the same change. 21600-21699 is the lowest free block in the 21500-21999 pool; verify it is still free on the host (ss -lntup) when installing, then add both the block row and a per-project table to the registry.
  • Gitea (s5) must be able to reach s4 on 10.255.255.12:21600 over WireGuard. Verify before adding grindex.
  • adnanh/webhook is not installed on s4 or s5. Nothing runs yet.
  • grindex is deliberately absent from the manifest. Its stack lives at /home/fisher/S/traefik-systems/grindex/ on s4, which is not in git and whose compose project names have not been verified on the host. Adding it from guesswork would be exactly the sloppiness this repo exists to remove.
  • webdev/domaindingo still carries scripts/deploy-*.sh. They should be deleted there once this is live, the way paradicsomleves' were.
S
Description
No description provided
Readme
132 KiB
Languages
Shell 59.5%
Python 40.5%