A lightweight HTTP webhook server written in Go. Trigger shell scripts from HTTP requests with built-in secret validation, rate limiting, and configurable execution controls. Runs standalone or in Docker.
- Secret validation — HMAC-SHA256/SHA1/SHA512 signatures or bearer tokens
- Trigger rules — composable
and/or/notconditions on payload fields, headers, query params, or IP ranges - Rate limiting — sliding window per hook
- Concurrency control — limit simultaneous executions per hook
- Command timeouts — kill long-running commands automatically
- Fire-and-forget — return a response immediately and run the script in the background
- Hot reload — edit your config without restarting (
-hotreloadorSIGHUP) - Proxy-aware — correct client IP resolution behind reverse proxies
- Structured logging — JSON or text output via
log/slog - Health endpoint —
/health - No secret in config — use
env:VARorfile:/pathto keep secrets out of config files
Binary — download the latest release for your platform from the releases page:
tar -xzf hooky_*_linux_amd64.tar.gz
sudo mv hooky /usr/local/bin/Docker:
docker pull ghcr.io/virtuallytd/hooky:latestFrom source:
go install hooky@latest- Create a
hooks.yaml:
hooks:
- id: deploy
command: /scripts/deploy.sh
secret:
type: hmac-sha256
header: X-Hub-Signature-256
value: env:DEPLOY_SECRET- Run:
DEPLOY_SECRET=mysecret hooky -hooks hooks.yaml- Trigger it:
BODY='{"ref":"main"}'
SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "mysecret" | awk '{print $2}')
curl -X POST http://localhost:9000/hooks/deploy \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: sha256=$SIG" \
-d "$BODY"Hooks are defined in a YAML or JSON file. Pass the path with -hooks.
hooks:
- id: string # URL endpoint: /hooks/{id}
command: string # Executable to run
working-dir: string # Working directory for the command
timeout: duration # e.g. 30s, 5m (default: 30s)
http-methods: [POST] # Allowed HTTP methods (default: [POST])
fire-and-forget: false # Return 200 immediately, run script in background
max-concurrent: 0 # Max simultaneous executions (0 = unlimited)
secret: # Validate the incoming request
type: hmac-sha256 # hmac-sha1 | hmac-sha256 | hmac-sha512 | token
header: X-Hub-Signature-256
query: token # Alternative: read token from query parameter
value: env:MY_SECRET # env:VAR, file:/path, or a literal string
trigger-rule: # Additional conditions (optional)
and:
- match:
type: value # value | regex | ip-whitelist | payload-hmac-sha256 | ...
parameter:
source: payload # payload | header | query | request | raw-body
name: event # dot-notation for nested fields: repository.full_name
value: push
args: # Positional arguments passed to the command
- source: payload
name: ref
env: # Environment variables for the command
- name: GIT_REF
source: payload # payload | header | query | env | literal
key: ref
response:
success-code: 200
error-code: 500
mismatch-code: 403 # Returned when secret or trigger rules fail
message: "Triggered."
include-output: false # Stream stdout/stderr back to the caller
headers:
X-Custom: value
rate-limit:
requests: 10
window: 1m| Source | Description |
|---|---|
payload |
JSON body field. Supports dot-notation: repository.full_name |
header |
HTTP request header |
query |
URL query parameter |
request |
Request metadata. Supported names: remote-addr |
raw-body |
The raw, unparsed request body |
literal |
A hard-coded string value (use name as the value) |
entire-payload |
The full JSON body as a string |
entire-headers |
All headers serialised as JSON |
entire-query |
All query parameters serialised as JSON |
The value field in secret and trigger rule secret fields supports three formats:
| Format | Description |
|---|---|
env:MY_VAR |
Read from the $MY_VAR environment variable |
file:/run/secrets/token |
Read from a file (whitespace trimmed) |
literal-value |
Used as-is |
Rules can be nested with and, or, and not:
trigger-rule:
and:
- match:
type: value
parameter: {source: payload, name: event}
value: push
- or:
- match:
type: regex
parameter: {source: payload, name: ref}
value: ^refs/heads/main$
- match:
type: ip-whitelist
ip-range: 10.0.0.0/8Match types:
| Type | Description |
|---|---|
value |
Exact string match |
regex |
Go regular expression match |
ip-whitelist |
CIDR range check (uses ip-range field) |
payload-hmac-sha1 |
HMAC-SHA1 signature of the raw body |
payload-hmac-sha256 |
HMAC-SHA256 signature of the raw body |
payload-hmac-sha512 |
HMAC-SHA512 signature of the raw body |
-hooks string Path to hooks config file, JSON or YAML (default: hooks.yaml)
-addr string Address to listen on (default: :9000)
-prefix string URL prefix for hook endpoints (default: hooks)
-cert string TLS certificate file — enables HTTPS when set
-key string TLS private key file
-hotreload Watch config file and reload on change
-log-format string Log format: text | json (default: text)
-log-level string Log level: debug | info | warn | error (default: info)
-proxy-header string Header to use for the real client IP (e.g. X-Forwarded-For)
-version Print version and exit
Hooky listens on port 9000 by default. Change it with -addr:
hooky -hooks hooks.yaml -addr :8080Behind a reverse proxy (recommended) — run hooky on localhost and let nginx or Caddy handle TLS and public traffic. Pass -proxy-header X-Forwarded-For so IP whitelist rules see the real client IP:
hooky -hooks hooks.yaml -proxy-header X-Forwarded-ForExample nginx config:
location / {
proxy_pass http://localhost:9000;
proxy_set_header X-Forwarded-For $remote_addr;
}With built-in TLS — hooky can terminate TLS directly without a reverse proxy:
hooky -hooks hooks.yaml -cert cert.pem -key key.pemA systemd unit file is provided in init/systemd/hooky.service.
1. Create a dedicated user:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin hooky2. Install the binary:
sudo mv hooky /usr/local/bin/hooky
sudo chmod +x /usr/local/bin/hooky3. Create the config directory and add your files:
sudo mkdir -p /etc/hooky /opt/hooky/scripts
sudo cp hooks.yaml /etc/hooky/hooks.yaml
sudo cp .env.example /etc/hooky/.env
# edit /etc/hooky/.env with your real secrets
sudo chown -R hooky:hooky /etc/hooky
sudo chmod 750 /etc/hooky
sudo chmod 640 /etc/hooky/.env
sudo chown -R root:hooky /opt/hooky/scripts
sudo chmod 750 /opt/hooky/scripts4. Install and start the service:
sudo cp init/systemd/hooky.service /etc/systemd/system/hooky.service
sudo systemctl daemon-reload
sudo systemctl enable --now hooky5. Check it is running:
sudo systemctl status hooky
sudo journalctl -u hooky -fNote: If your scripts need to run Docker commands, add the
hookyuser to thedockergroup:sudo usermod -aG docker hooky
| Path | Purpose |
|---|---|
/usr/local/bin/hooky |
Binary |
/etc/hooky/hooks.yaml |
Hook configuration |
/etc/hooky/.env |
Secrets and environment variables |
/etc/systemd/system/hooky.service |
Systemd unit file |
/opt/hooky/scripts/ |
Hook scripts |
Hooky writes structured output to stdout which systemd captures automatically. Logs are managed by journald — no separate log files or log rotation needed.
# Follow live logs
sudo journalctl -u hooky -f
# Show logs since last boot
sudo journalctl -u hooky -b
# Show logs for a specific time range
sudo journalctl -u hooky --since "2026-01-01 00:00:00" --until "2026-01-01 23:59:59"Each log line includes the hook id so you can filter by a specific hook:
sudo journalctl -u hooky -f | grep "hook=deploy"The hook id in your hooks.yaml is what appears in logs, so use descriptive names that make log output easy to read — e.g. api-deploy, worker-restart rather than generic names like hook1.
Pull the hooky image from the GitHub Container Registry (no authentication required — the image is public):
docker pull ghcr.io/virtuallytd/hooky:latestRun with a config file and scripts directory:
docker run -p 9000:9000 \
-v ./hooks.yaml:/app/hooks.yaml:ro \
-v ./scripts:/app/scripts:ro \
--env-file .env \
ghcr.io/virtuallytd/hooky:latestOr use the provided docker-compose.yml:
cp .env.example .env
# edit .env with your real secrets
docker compose up -dAuthenticating to a private registry — if your deploy scripts pull images from a private registry (e.g. a private GitHub Container Registry repository), pass the credentials to hooky via /etc/hooky/.env so the deploy script can log in before pulling:
# /etc/hooky/.env — registry credentials for the deploy script
REGISTRY=ghcr.io
REGISTRY_USER=myorg
REGISTRY_TOKEN=ghp_xxxxxxxxxxxx # GitHub PAT with read:packages scopeThe deploy script can then authenticate using these variables:
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
docker compose pull myserviceSee the examples/ directory for a complete worked example including this auth pattern.
If your scripts need to control other containers on the host, mount the Docker socket:
volumes:
- /var/run/docker.sock:/var/run/docker.sockWarning: Mounting the Docker socket gives the container full control over the host's Docker daemon. Ensure the server is not publicly accessible without authentication.
A complete end-to-end example — deploy script, hooky config, and GitHub Actions workflow — is available in the examples/ directory.
Releases are automated via GitHub Actions and GoReleaser. To cut a release:
git tag v1.0.0
git push origin v1.0.0This triggers the release workflow which:
- Builds binaries for
linux/amd64andlinux/arm64 - Creates a GitHub release with archives and a
checksums.txt - Builds a multi-arch Docker image and pushes it to
ghcr.io/virtuallytd/hooky
The Docker image is tagged with both the version (v1.0.0) and latest.
# Run all tests
go test ./...
# Run a specific package
go test ./internal/hook/...
# Run a single test
go test ./internal/server/... -run TestHook_HMAC_Valid
# Run with verbose output
go test ./... -v
# Run with the race detector
go test -race ./...| Package | Coverage |
|---|---|
internal/config |
YAML and JSON loading, default values, validation (missing ID/command, duplicate IDs), env: and file: secret resolution |
internal/hook |
Parameter extraction from all sources (payload with dot-notation, header, query, raw-body), HMAC-SHA1/256/512 validation, token auth with Bearer prefix stripping, all trigger rule types (value, regex, ip-whitelist, payload-hmac-*), boolean rule composition (and/or/not), rate limiting (allow, block, window reset), command execution (success, failure, exit codes, timeout, working directory, env var passing, concurrency limits, fire-and-forget) |
internal/server |
Full HTTP request lifecycle — routing, method enforcement, secret validation, trigger rules, rate limiting, custom response headers, proxy IP resolution, hot reload via SetConfig, graceful shutdown, end-to-end test from config file on disk through to command output |
Tests run in CI on every push and pull request via GitHub Actions.
See CONTRIBUTING.md.