A self-hosted webhook runner. Point a GitHub (or generic) webhook at it, and
when a webhook fires, webhookr does a git pull on the configured branch and
runs your deploy command — logging everything so you can tail it later.
Projects can also be cloned automatically from a repository URL and deployed with built-in Docker Compose presets. An optional Cloudflare Tunnel can publish the webhook listener at a real HTTPS hostname without opening port 9000.
Manage it from a terminal UI, a CLI, or an optional browser dashboard.
Every merge to master builds release binaries and publishes them to the
latest GitHub release. Install with one line:
curl -fsSL https://github.com/kodin00/webhookr/releases/latest/download/install.sh | shThe installer picks the Linux x86_64 binary, verifies its checksum, installs
it to /usr/local/bin/webhookr
(override with WEBHOOKR_INSTALL_DIR), and sets up a webhookr systemd
service that starts on boot and restarts on failure.
Easiest is from the app itself: Settings → Version → Update and restart in
the web UI, or Update webhookr (v) in the TUI. Both download the published
build, verify its checksum, and replace the binary. From a shell:
webhookr version # what is running, and what is published
webhookr self-update # replace this binary with the published buildRe-running the install one-liner also works. It overwrites the binary, rewrites the systemd unit, and restarts the service. Two things to know about that path:
- The unit is regenerated from scratch every time, with
User=set to whoever invoked the script. Run it from the same account as before — ifUser=changes, systemd hands the daemon a different$HOME, it reads a differentconfig.json, and every project appears to have vanished. Any other hand-edits to the unit are overwritten too. releases/latest/downloadserves whateverlatestpoints at right now, with no error if that is still the previous build. If you push and install within the same couple of minutes, you can silently reinstall the old binary — which is exactly whatwebhookr versionis for.
Your config (~/.config/webhookr/) and run history are left untouched either
way. To build from source instead, see Build.
A release is a single rolling latest tag, so the tag says nothing about what
is in it. Every build therefore carries the commit it came from:
$ webhookr version
running: 0.2.0 (73deef9)
published: 0.2.0 (73deef9) (up to date)The same pair is on Settings → Version in the web UI and in the TUI banner.
(local build) instead of a commit means the binary was built from a checkout
rather than installed from a release.
Self-update needs write access to the installed binary. The daemon deliberately
does not run as root, so if /usr/local/bin/webhookr is root-owned the update
will say so and the install one-liner (which uses sudo) is the way through.
Updating from the web UI or serve also exits the process on purpose, so the
service manager starts the new binary; if you are running webhookr serve in a
terminal rather than under systemd, it will not come back by itself.
- Each project maps a URL slug (
/hooks/<id>) to a Git checkout plus a deployment preset or custom shell command. - Incoming webhooks are authenticated against a per-project secret, then
git fetch/checkout/pullruns before the command. - Projects and the listen address live in one JSON config file, shared between the daemon and the management CLI/TUI.
- Run history and per-run logs are written to a separate state directory. Logs
stream to disk as commands produce them, so a long
docker compose buildcan be followed while it runs rather than only after it finishes. - Only one run per project happens at a time; a trigger arriving while that
project is already deploying is refused rather than queued, so two pushes
cannot race
git pullon the same checkout.
- Rust (installed via mise) —
.mise.tomlpins the toolchain. - Git, plus Docker with the Compose plugin for Compose presets.
- An installed
cloudflaredbinary or Docker when using Cloudflare Tunnel.
mise exec -- cargo build --releaseThe binary is target/release/webhookr.
# 1. Register a project (prompts for anything you don't pass)
webhookr add \
--name "My Site" \
--path /srv/my-site \
--repository https://github.com/me/my-site.git \
--branch main \
--preset compose_build \
--compose-file compose.production.yaml
# 2. Get the secret + webhook URL (printed on add, or via:)
webhookr key --id my-site
# 3. Start the daemon in the foreground (systemd recommended for production)
webhookr serve
# ...or launch the interactive TUI
webhookrwebhookr add prints the generated secret once; use webhookr key --id <id>
to show it again, and --rotate to roll it.
Run webhookr --help for the same list.
| Command | Description |
|---|---|
webhookr |
Launch the interactive TUI (blocks until you quit). |
webhookr serve [-p, --port <PORT>] [--web] [--no-web] [--web-port <PORT>] |
Run the daemon in the foreground; --port overrides the configured port. --web starts the admin UI for this run only. |
webhookr web enable [--addr <ADDR>] [--hostname <HOST>] |
Turn the browser admin UI on (persisted). |
webhookr web disable / webhookr web status |
Turn it off / show its status. |
webhookr status |
Show the listen address, whether the daemon is up, and every project with its webhook URL and last-run status. |
webhookr list |
Table of configured projects, deployment presets, and last-run state. |
webhookr add [...] |
Add a project from a local checkout or repository URL. |
webhookr edit --id <ID> [...] |
Update source, deployment preset, Compose file/profiles, or webhook settings. |
webhookr remove --id <ID> [--yes] |
Remove a project (prompts for confirmation unless --yes). |
webhookr key --id <ID> [--rotate] |
Show the project's secret (and webhook URL); --rotate generates a new one. |
webhookr logs --id <ID> [--lines <N>] |
Tail the latest run's log (default 50 lines). |
webhookr run --id <ID> [--no-pull] |
Pull the latest source, then run the deployment; --no-pull deploys the checkout as-is. |
webhookr update --id <ID> |
Clone or fast-forward the source, then deploy it. |
webhookr cloudflare [--hostname hooks.example.com] [--admin-hostname deploy.example.com] |
Provision a Cloudflare Tunnel; at least one hostname required. Reads CLOUDFLARE_API_TOKEN or --api-token. |
webhookr version [--offline] |
Show the running build and compare it with the published one. |
webhookr self-update [--check] |
Replace this binary with the published build; --check only reports. |
The add/edit wizard and CLI support four deployment modes:
| Preset | Behavior |
|---|---|
compose_build |
docker compose -f <file> up -d --build --remove-orphans |
compose_pull |
Pull images, then run Compose detached with orphan cleanup. |
compose_up |
Run Compose detached without forcing a pull or rebuild. |
custom |
Run the configured shell command from the project directory. |
Use --compose-profile <name> more than once to enable Compose profiles.
Compose files must be relative to the checkout and cannot use .. to escape it.
Set an access token on the project (web UI, Source section) to clone and
pull private repos over HTTPS — a GitHub personal access token with read access
to the repository. It is handed to git through a credential helper that reads an
environment variable, so it never appears in the process list and is never
written into the checkout's .git/config.
If --repository is set and the project path does not exist, the first update
clones that branch into the path. Later updates fetch, check out, and fast-forward
the same branch. Existing configs continue to use the custom preset.
An optional browser dashboard that does everything the TUI does — add, edit and remove projects, reveal and rotate secrets, trigger deploys, watch run logs stream live, and configure the Cloudflare Tunnel — with ordinary web forms instead of a nine-step terminal wizard.
It has no login. Anyone who can reach it can set a project's deploy command and run it as the daemon's user, and can replace the webhookr binary with the published build. Put Cloudflare Access (or an equivalent) in front of it before exposing it, and set
User=in the systemd unit so that user is not root.
It is off by default. Turn it on with:
webhookr web enable --addr 127.0.0.1:9001
sudo systemctl restart webhookrThe UI runs inside the same webhookr serve process as the webhook listener.
Its port serves both surfaces: the dashboard at /, and webhooks at
/webhook/<id> (with /hooks/<id> kept as an alias). So one hostname is
enough for everything.
The default bind is 127.0.0.1, which is deliberate and still works through a
Cloudflare Tunnel: cloudflared runs on the same host and connects to
http://127.0.0.1:<port>. Binding to 0.0.0.0 would additionally expose the
panel on the LAN and the server's public IP, where Access cannot protect it.
To publish it on one hostname, provision the tunnel with only an admin hostname — the admin port already serves webhooks too:
export CLOUDFLARE_API_TOKEN='scoped-token'
webhookr cloudflare --admin-hostname deploy.example.com
sudo systemctl restart webhookrThat gives you https://deploy.example.com for the dashboard and
https://deploy.example.com/webhook/<id> for GitHub.
Access needs a path exception. GitHub cannot complete an Access login, so a policy covering the whole hostname will silently break every delivery. In the Access application for
deploy.example.com, add a Bypass policy for the path/webhook/*(and/hooks/*if you use the alias) ahead of your normal policy. Those paths stay protected by the per-project HMAC/token check.
If you would rather keep them apart, pass both and Access only the admin host:
webhookr cloudflare --hostname hooks.example.com --admin-hostname deploy.example.comFor a quick look without persisting anything, and without exposing it at all:
webhookr serve --web --web-port 9001 # then browse http://127.0.0.1:9001| Page | What it does |
|---|---|
/ |
Project cards with live status badges, and one-click deploy buttons. |
/projects/new, /projects/{id}/edit |
The whole project form on one page, with a server-side directory picker for the checkout path. |
/projects/{id} |
Config, webhook URL, reveal-on-click secret, recent runs. |
/runs, /runs/{id} |
Run history and a log view that streams while a deploy is running, then stops polling by itself. |
/settings, /settings/cloudflare |
Listen addresses, the running build with an update button, and tunnel provisioning. |
/webhook/{id} |
The webhook receiver, on this same port. Not behind the CSRF or Access-header checks — it authenticates with the project secret instead. |
Requests that change anything are rejected unless the browser reports them as
same-origin, so a page on another site cannot drive the panel using your Access
session. Set require_access_header in the config to additionally reject any
request that did not arrive through Cloudflare Access.
There is no build step and no CDN: the UI is server-rendered HTML, and htmx plus the stylesheet are compiled into the binary.
| Key | Action |
|---|---|
j / ↓ |
Select next project |
k / ↑ |
Select previous project |
a |
Add a project |
e |
Edit the selected project |
d |
Delete the selected project |
r |
Run the selected project |
u |
Open Update app: clone/pull and deploy the selected project |
l |
View the selected project's log |
q |
Quit |
c |
Configure Cloudflare Tunnel |
w |
Toggle the web admin UI on or off |
v |
Update webhookr to the published build |
In your repo: Settings → Webhooks → Add webhook:
- Payload URL:
http://<vps-ip>:9000/hooks/<id> - Content type:
application/json - Secret: the project's secret (
webhookr key --id <id>)
webhookr verifies the X-Hub-Signature-256 header against the project's
secret before running anything, so only requests signed with the secret are
accepted.
To get the deploy result back onto the commit as a green check or red X, see GitHub commit status.
webhookr can report each deploy back to GitHub as a commit status, so the commit you pushed shows the pending dot, green check or red X on the repository page, the commits list and any pull request that contains it. Clicking Details opens that run's log.
It is off by default. Turn it on per project in the web admin UI, under
GitHub commit status on the project form. Three settings:
| Setting | What it does |
|---|---|
| Report deploy results | The master switch for this project. |
| Status token | A token that can write commit statuses. Leave blank to reuse the project's access token. |
| Context | The label GitHub shows beside the status. Defaults to webhookr/<id>. |
The token needs repo:status on a classic personal access token, or
Commit statuses: write on a fine-grained one scoped to the single
repository. repo:status is deliberately narrow — it grants no access to the
repository's code. A fine-grained token scoped only to read contents (the right
scope for cloning) cannot write statuses, which is why this is a separate
field; when one token covers both, leave it blank and the access token is used.
The default context is per project, so two webhookr projects deploying from one repository do not overwrite each other's indicator.
pendingwhen the deploy starts.successwhen it finishes, described as e.g.deployed in 47s.failurewhen the deploy command exits non-zero, described with the last line of its output.errorwhen the source could not be fetched at all, so nothing was deployed.
The status lands on the commit named in the push payload when the push was to
the project's branch, and on whatever git rev-parse HEAD reports otherwise —
including for manual deploys (webhookr run, webhookr update, the TUI, the
admin UI's Deploy button). That is deliberate: a redeploy after a fix clears a
stale red X on the commit that is actually live.
If the pull moves HEAD past the commit that was announced — a newer push landed
first — both commits get the final status, so nothing is left showing pending
forever.
A push that arrives while that project is already deploying is refused rather
than queued (see How it works), and gets a terminal error
saying so. In the usual case the in-flight run's own git pull picks that
commit up anyway, and its result supersedes the error on the same commit.
Posting a status can never fail or delay a deploy. Anything that goes wrong —
an unreachable API, a rejected token, a commit GitHub does not know about — is
written into the run log as a # github status: line and otherwise ignored.
Details points at /runs/<run-id> on the admin UI, which needs a public
hostname to be worth sending. webhookr uses the tunnel's admin hostname if
there is one; with only a loopback address configured the link is omitted
rather than pointing at 127.0.0.1, and GitHub simply shows no link. The
webhook hostname is never used for it — that one routes to a listener that does
not serve the run pages.
Following the link hits Cloudflare Access normally and logs you in; unlike
/webhook/*, it needs no bypass policy, because a person is clicking it.
Derived from the repository URL, with nothing to configure: a github.com
remote uses api.github.com, and any other host is treated as GitHub
Enterprise Server at https://<host>/api/v3.
webhookr can post each deploy to a Telegram chat through a bot you own:
🚀 when a run starts, ✅ when it succeeds, ❌ when it fails — quoting the
tail of the run log on a failure. It is off by default, and it is global: one
bot and one chat for every project, configured under Settings in the web
admin UI.
| Setting | What it does |
|---|---|
| Notify a Telegram chat about deploys | The master switch. |
| Bot token | A token from @BotFather. Stored like the other secrets: the form never echoes it back, blank keeps the stored one. |
| Chat id | The chat to post to. Group chats have negative ids; @channelname also works. |
To find a group's chat id: create the bot with @BotFather, add it to the
group, send any message in the group, then open
https://api.telegram.org/bot<token>/getUpdates and read message.chat.id —
it will start with -100.
🚀 deploy started — <project>with the run and commit when the deploy begins.✅ deploy succeededwith the duration and the deploy's summary line.❌ deploy failedwith the duration, the error, and the last part of the run log (ANSI stripped, capped so the whole message stays under Telegram's 4096-character limit).
When the admin UI has a public hostname, each message ends with a link to that
run's log page. With only a loopback address configured the link is omitted,
for the same reason as the GitHub Details link: a 127.0.0.1 link is dead for
everyone who taps it.
Posting a message can never fail or delay a deploy. Anything that goes wrong —
an unreachable API, a rejected token, a chat the bot has not been added to — is
written into the run log as a # telegram: line and otherwise ignored. The
final message is retried once; the started message is not, since the final
one supersedes it minutes later anyway.
The bot token lives in config.json alongside the other secrets, in a file
webhookr keeps owner-only.
For non-GitHub senders, set the project's verify_mode to token
(webhookr add --verify-mode token, or webhookr edit --verify-mode token).
Then send the secret in a header:
X-Webhookr-Key: <secret>
Any POST to the hook URL carrying the correct X-Webhookr-Key header
triggers the deploy.
Choose Cloudflare tunnel on the main screen, or run:
export CLOUDFLARE_API_TOKEN='scoped-token'
webhookr cloudflare --hostname hooks.example.com
sudo systemctl restart webhookrThe token needs Zone Read, DNS Write, and Cloudflare Tunnel Write for the
target account/zone. webhookr uses it once to create or update a remotely
managed tunnel, its ingress rule, and a proxied CNAME. The API token is not
stored. Only the narrower runtime tunnel token is saved to
~/.config/webhookr/cloudflare-credentials.json with owner-only permissions.
At runtime webhookr starts an installed cloudflared binary, or falls back to
the official cloudflare/cloudflared Docker image with host networking. Restart
the daemon after changing tunnel configuration. The public webhook URL becomes
https://hooks.example.com/hooks/<id>.
- Config:
~/.config/webhookr/config.jsonon Linux (override withWEBHOOKR_CONFIGorWEBHOOKR_CONFIG_DIR). - Logs / run history:
~/.local/share/webhookr/on Linux (override withWEBHOOKR_STATE_DIR). Per-run logs live under~/.local/share/webhookr/logs/runs/<run-id>.log, indexed by~/.local/share/webhookr/runs.json.
./deploy/install.shThe script builds the release binary, installs it to /usr/local/bin/webhookr,
installs the webhookr.service unit, and enables/starts it. The daemon runs in
the foreground under systemd with Restart=always. Before starting, edit
deploy/webhookr.service to uncomment and set User=/Group= to the account
that owns the project checkouts (they must be writable by that user).