Skip to main content

Webhook Automation with Hooky

·2228 words·11 mins

What is Hooky #

Hooky is a lightweight HTTP webhook server written in Go. It listens for incoming HTTP requests and executes shell scripts in response. It supports HMAC signature validation, bearer token auth, composable trigger rules, rate limiting, concurrency controls, and structured logging. It ships as a single static binary with no runtime dependencies.

This is the tool I now use to handle all webhook-driven automation on my servers, replacing the older setup I documented in a previous post.

High Level Workflow #

The general pattern for any hooky-driven automation looks like this:

  1. A GitHub Actions workflow builds and pushes a new container image
  2. The workflow fires an HMAC-signed HTTP POST to hooky on your server
  3. Hooky validates the signature and executes a deploy script
  4. The script pulls the new image and restarts the container

Installing on CentOS #

Download the Binary #

Grab the latest release from the releases page. For a 64-bit CentOS server:

curl -LO https://github.com/virtuallytd/hooky/releases/download/v1.0.0/hooky_1.0.0_linux_amd64.tar.gz
tar -xzf hooky_1.0.0_linux_amd64.tar.gz
sudo mv hooky /usr/local/bin/hooky
sudo chmod +x /usr/local/bin/hooky

Verify it works:

hooky -version

Create the Hooky User #

Run hooky under a dedicated system account. Add it to the docker group so deploy scripts can manage containers:

sudo useradd --system --no-create-home --shell /sbin/nologin hooky
sudo usermod -aG docker hooky

Create the Directory Structure #

sudo mkdir -p /etc/hooky
sudo mkdir -p /opt/hooky/scripts
sudo chown -R hooky:hooky /etc/hooky
sudo chown -R hooky:hooky /opt/hooky

Authentication Mechanisms #

Hooky supports four secret types. The right choice depends on who is sending the request and how much you trust them.

hmac-sha256 #

Use this for any webhook from a system you do not control: GitHub, Gitea, Forgejo, and most modern CI or payment platforms all support HMAC-SHA256. The sender signs the raw request body with a shared secret, and hooky verifies that signature before doing anything. This means both the authenticity (who sent it) and the integrity (the body was not tampered with in transit) are verified. This is the default choice for anything external.

secret:
  type: hmac-sha256
  header: X-Hub-Signature-256
  value: env:DEPLOY_SECRET

hmac-sha1 #

Use this only when the sending system does not support SHA256. Some older GitHub webhook configurations and a handful of legacy services (Bitbucket Server, older Jenkins plugins) still use SHA1. It provides the same body integrity guarantees as SHA256 but with a weaker hash function. Prefer SHA256 wherever possible.

secret:
  type: hmac-sha1
  header: X-Hub-Signature
  value: env:DEPLOY_SECRET

hmac-sha512 #

Use this when you control both ends and want stronger signing than SHA256. It is rarely required in practice (SHA256 is not a weak choice) but it is available if your internal tooling or security policy mandates it.

secret:
  type: hmac-sha512
  header: X-Signature-512
  value: env:DEPLOY_SECRET

token #

Use this for internal automation where you control both the sender and the receiver: cron jobs, internal scripts, Ansible playbooks, or anything running on infrastructure you own. A bearer token is simpler to generate and send than an HMAC signature, but it only proves the caller knows the token; it does not verify the request body was not modified in transit. Do not use this for webhooks from external services.

secret:
  type: token
  header: Authorization   # expects: Bearer <token>
  value: env:PING_TOKEN
MechanismBody integrityUse when
hmac-sha256YesExternal services (GitHub, Gitea, etc.)
hmac-sha1YesLegacy services that only support SHA1
hmac-sha512YesInternal tooling with stricter signing requirements
tokenNoInternal scripts and cron jobs you control

Secret Externalization #

Regardless of which mechanism you use, the value field supports two safe ways to avoid putting secrets directly in hooks.yaml:

  • env:VAR_NAME: reads the value from an environment variable at startup
  • file:/path/to/secret: reads the value from a file on disk

For the systemd setup in this post, env:DEPLOY_SECRET is the right choice. The value is loaded from /etc/hooky/.env via the EnvironmentFile directive in the unit file, so it never appears in the config file or in the process list.

Configuration #

Create hooks.yaml #

Create /etc/hooky/hooks.yaml. This defines the deploy hook, secured with HMAC-SHA256 to match the signature format GitHub Actions sends:

hooks:
  - id: deploy
    command: /opt/hooky/scripts/deploy.sh
    working-dir: /opt/hooky/scripts
    timeout: 5m
    max-concurrent: 1

    secret:
      type: hmac-sha256
      header: X-Hub-Signature-256
      value: env:DEPLOY_SECRET

    # Only deploy when the push is to the main branch.
    trigger-rule:
      match:
        type: regex
        parameter:
          source: payload
          name: ref
        value: ^refs/heads/main$

    # Pass request data as environment variables to the script.
    env:
      - name: GIT_REF
        source: payload
        key: ref
      - name: REPO
        source: payload
        key: repository.full_name

    response:
      message: "Deployment triggered."
      include-output: false

The value: env:DEPLOY_SECRET line tells hooky to read the secret from an environment variable at runtime; it never lives in the config file. The trigger-rule ensures the deploy script only runs when a push lands on main; pushes to other branches are validated and acknowledged but do not trigger execution.

Create the Deploy Script #

Create /opt/hooky/scripts/deploy.sh. This is what hooky executes when a valid webhook arrives:

#!/bin/bash
# deploy.sh - pulls the latest image and restarts a Docker Compose service.
#
# Expected environment variables (set via hooks.yaml env: block):
#   GIT_REF  - the git ref that triggered the deployment (e.g. refs/heads/main)
#   REPO     - the repository name (e.g. myorg/myapp)
#
# Optional environment variables (set in /etc/hooky/.env for private registries):
#   REGISTRY       - registry hostname, e.g. ghcr.io (default: ghcr.io)
#   REGISTRY_USER  - registry username or organisation
#   REGISTRY_TOKEN - personal access token with read:packages scope

set -euo pipefail

# ── Configuration ─────────────────────────────────────────────────────────────
COMPOSE_DIR="/opt/myapp"
SERVICE="myapp"
LOG_FILE="/var/log/hooky/deploy.log"

# ── Logging ───────────────────────────────────────────────────────────────────
mkdir -p "$(dirname "$LOG_FILE")"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}

# ── Deploy ────────────────────────────────────────────────────────────────────
log "Starting deployment"
log "  Repository : ${REPO:-unknown}"
log "  Ref        : ${GIT_REF:-unknown}"
log "  Directory  : $COMPOSE_DIR"
log "  Service    : $SERVICE"

# Authenticate to the registry if credentials are provided.
# Set REGISTRY, REGISTRY_USER, and REGISTRY_TOKEN in /etc/hooky/.env to enable
# this for private registries (e.g. a private GitHub Container Registry repo).
if [[ -n "${REGISTRY_TOKEN:-}" ]]; then
    REGISTRY="${REGISTRY:-ghcr.io}"
    log "Logging in to registry: $REGISTRY"
    echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" \
        -u "${REGISTRY_USER:?REGISTRY_USER must be set}" \
        --password-stdin 2>&1 | tee -a "$LOG_FILE"
fi

cd "$COMPOSE_DIR"

log "Pulling latest image..."
docker compose pull "$SERVICE" 2>&1 | tee -a "$LOG_FILE"

log "Restarting service..."
docker compose up -d --no-deps "$SERVICE" 2>&1 | tee -a "$LOG_FILE"

log "Deployment complete"

Make it executable:

sudo chmod +x /opt/hooky/scripts/deploy.sh
sudo chown hooky:hooky /opt/hooky/scripts/deploy.sh

Set COMPOSE_DIR and SERVICE to match your application. For private registries (including private GitHub Container Registry repos), add REGISTRY_TOKEN, REGISTRY_USER, and REGISTRY to /etc/hooky/.env; the script will authenticate before pulling.

Output from the script goes to both the systemd journal and /var/log/hooky/deploy.log. Use journalctl -u hooky -f for a live view or tail the log file directly.

Create the Environment File #

Create /etc/hooky/.env with the shared secret that GitHub Actions will use to sign requests:

DEPLOY_SECRET=your-strong-secret-here

Lock it down so only the hooky user can read it:

sudo chown hooky:hooky /etc/hooky/.env
sudo chmod 600 /etc/hooky/.env

Systemd Service #

The hooky repository ships a systemd unit at init/systemd/hooky.service. Install it:

sudo curl -o /etc/systemd/system/hooky.service \
  https://raw.githubusercontent.com/virtuallytd/hooky/main/init/systemd/hooky.service

The unit runs hooky as the hooky user, loads secrets from /etc/hooky/.env, and includes systemd hardening options. It listens on port 9000 by default.

Enable and start the service:

sudo systemctl daemon-reload
sudo systemctl enable --now hooky

Check it is running:

sudo systemctl status hooky
sudo journalctl -u hooky -f

You should see:

hooky[1234]: {"time":"2026-03-12T10:00:00Z","level":"INFO","msg":"starting hooky","addr":":9000","hooks":1}
hooky[1234]: {"time":"2026-03-12T10:00:00Z","level":"INFO","msg":"hook registered","id":"deploy"}

The unit file’s ReadWritePaths directive grants the hooky user write access to /etc/hooky, /var/log/hooky, /opt/hooky, and /home/hooky. The /home/hooky path is needed if your deploy script authenticates to a container registry; Docker stores credentials in ~/.docker/config.json. Create it before starting the service:

sudo mkdir -p /home/hooky
sudo chown hooky:hooky /home/hooky

Firewall #

With hooky sitting behind Caddy, only Caddy needs to be reachable from the internet. Keep port 9000 closed externally and allow only local traffic to it:

sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload

If you need to test hooky directly from another machine during setup, temporarily open 9000 and remove it once Caddy is in front.

Running Behind Caddy #

Exposing hooky directly on port 9000 is fine for local testing, but in production you should put it behind a reverse proxy. Caddy handles HTTPS automatically via Let’s Encrypt, so it is a natural fit. Running it as a container means no package manager setup: just a Caddyfile and a compose file.

Directory Structure #

sudo mkdir -p /opt/caddy

Create the Caddyfile #

Create /opt/caddy/Caddyfile. Caddy proxies incoming HTTPS traffic to hooky, which is running as a systemd service on the host at port 9000.

Because Caddy is in a container and hooky is on the host, the upstream address uses host-gateway, a Docker alias that resolves to the host’s internal IP:

hooks.example.com {
    reverse_proxy host-gateway:9000
}

Caddy will automatically obtain and renew a TLS certificate for the domain. Make sure the DNS A record for hooks.example.com points to your server before starting the container.

Create the Compose File #

Create /opt/caddy/docker-compose.yml:

services:
  caddy:
    image: caddy:latest
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    extra_hosts:
      - "host-gateway:host-gateway"

volumes:
  caddy_data:
  caddy_config:

The extra_hosts entry adds a host-gateway entry to the container’s /etc/hosts pointing at the Docker host. The caddy_data volume persists the TLS certificates across container restarts.

Start it:

cd /opt/caddy
docker compose up -d

Firewall #

Open ports 80 and 443. Port 80 is needed for the ACME HTTP-01 challenge that Caddy uses to obtain the certificate:

sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload

Port 9000 does not need to be open externally; Caddy reaches it over the host’s loopback interface.

Tell Hooky to Trust the Proxy Header #

When hooky is behind a reverse proxy, the client IP it sees for every request is the container’s gateway IP rather than the real caller. To make rate limiting and IP whitelisting work correctly, start hooky with the -proxy-header flag.

Add a systemd override:

sudo systemctl edit hooky
[Service]
ExecStart=
ExecStart=/usr/local/bin/hooky -hooks /etc/hooky/hooks.yaml -log-format json -proxy-header X-Forwarded-For
sudo systemctl daemon-reload
sudo systemctl restart hooky

Hooky will now extract the real client IP from the X-Forwarded-For header that Caddy sets.

Verify the Setup #

Once both services are running, hit the health endpoint through Caddy over HTTPS:

curl -s https://hooks.example.com/health

Expected response: OK

Testing with Curl #

You can test against localhost directly or through Caddy. Use localhost while setting things up, and switch to the HTTPS URL once Caddy is in place.

Health Check #

# Direct
curl -s http://localhost:9000/health

# Through Caddy
curl -s https://hooks.example.com/health

Expected response: OK

Trigger the Deploy Hook #

Because hooky validates HMAC signatures, test requests need a correctly signed header:

SECRET="your-strong-secret-here"
PAYLOAD=$(jq -n \
  --arg ref "refs/heads/main" \
  --arg sha "abc1234" \
  --arg repo "myuser/myrepo" \
  '{ref: $ref, sha: $sha, repository: {full_name: $repo}}')

SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

curl --fail --silent --show-error \
  -X POST http://localhost:9000/hooks/deploy \
  -H "Content-Type: application/json" \
  -H "X-Hub-Signature-256: sha256=${SIG}" \
  -d "$PAYLOAD"

Expected response: Deployment triggered.

To confirm signature validation is working, try sending a bad signature; you should get HTTP 403 back.

GitHub Actions Integration #

Once hooky is running, add a step to the end of your workflow that fires the webhook after a successful image push.

Store two secrets in your repository (Settings → Secrets and variables → Actions):

  • DEPLOY_SECRET: the shared HMAC secret from /etc/hooky/.env
  • HOOKY_URL: the full base URL of your hooky instance (e.g. https://hooks.example.com)

Webhook Step #

      - name: Trigger deploy webhook
        run: |
          PAYLOAD=$(jq -n \
            --arg ref "${{ github.ref }}" \
            --arg sha "${{ github.sha }}" \
            --arg repo "${{ github.repository }}" \
            '{ref: $ref, sha: $sha, repository: {full_name: $repo}}')

          SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "${{ secrets.DEPLOY_SECRET }}" | awk '{print $2}')

          curl --fail --silent --show-error \
            -X POST "${{ secrets.HOOKY_URL }}/hooks/deploy" \
            -H "Content-Type: application/json" \
            -H "X-Hub-Signature-256: sha256=$SIG" \
            -d "$PAYLOAD"

Using jq -n to build the payload ensures the JSON is always valid regardless of special characters in branch names or repository paths. The --fail --silent --show-error flags on curl cause the step to fail on HTTP 4xx/5xx and print the response body, which is useful when debugging a rejected signature.

Full Example Workflow #

A complete workflow that builds a container image and then triggers hooky:

name: Build and deploy

on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Log into registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Trigger deploy webhook
        if: github.event_name != 'pull_request'
        run: |
          PAYLOAD=$(jq -n \
            --arg ref "${{ github.ref }}" \
            --arg sha "${{ github.sha }}" \
            --arg repo "${{ github.repository }}" \
            '{ref: $ref, sha: $sha, repository: {full_name: $repo}}')

          SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "${{ secrets.DEPLOY_SECRET }}" | awk '{print $2}')

          curl --fail --silent --show-error \
            -X POST "${{ secrets.HOOKY_URL }}/hooks/deploy" \
            -H "Content-Type: application/json" \
            -H "X-Hub-Signature-256: sha256=$SIG" \
            -d "$PAYLOAD"

The deploy script on the server receives GIT_REF and REPO as environment variables (as configured in hooks.yaml), so it knows exactly which ref triggered the deployment.