# How to deploy on git push with a webhook listener

Source: https://docs.quake.ai/docs/automation/how-to/webhook-deploy-flow
Markdown: https://docs.quake.ai/docs/automation/how-to/webhook-deploy-flow.md

---

# How to deploy on git push with a webhook listener

Run a small webhook listener on a VM you operate that redeploys your app when you push to its repository. A push fires a webhook from your forge, the listener verifies the request signature, and a deploy script pulls the new code or image and restarts the service. This is the do-it-yourself version of the push-to-deploy loop that platforms like [Coolify](/resources/deployments/deploy-coolify-host-template) run internally.



A webhook listener gives you push-to-redeploy on a single host. It does not run tests, build a matrix, cache layers, or fan work across runners. When you outgrow it, move to a [CI deploy from a pipeline](/docs/automation/how-to/app-cicd-vm) or [a self-hosted CI server](/docs/automation/how-to/self-host-ci-server). For the end-to-end build-and-deploy pattern, see [CI/CD pipelines](/resources/solutions/cicd-pipelines).



<PrerequisiteBlock>

- A VM in your Quake AI project running the app, either as a `systemd` service or a container. See [How to deploy an application to a Quake AI VM from CI](/docs/automation/how-to/app-cicd-vm) for the target setup.
- SSH access to that VM and `sudo` to install a service.
- A git repository on GitHub, GitLab, or Forgejo, with permission to add a webhook.
- `git` (for the pull-and-restart flow) or `docker` (for the image flow) on the VM.
- The [`webhook`](https://github.com/adnanh/webhook) tool, a single static binary that receives HTTP requests and runs a command. Install it from your distribution's package manager or the project's release.

</PrerequisiteBlock>

## How the flow works

<Figure size="md" caption="A signed push from your forge reaches the webhook listener on your VM, which runs deploy.sh and restarts the app">

```d2
direction: right

dev: You {shape: person}
forge: Forge\n(GitHub / GitLab / Forgejo)
fip: Floating IP
vm: Your VM {
  webhook: webhook listener
  deploy: deploy.sh
  app: App service
  webhook -> deploy: verified push
  deploy -> app: restart
}

dev -> forge: git push
forge -> fip: POST /hooks/deploy\n(signed)
fip -> vm.webhook
```

</Figure>

The listener is the only new moving part. It is also the one exposed to the network, so the security of this flow rests on two things: the listener accepts only requests it can verify came from your forge, and its port is reachable only over a path you control.

## Step 1: Scope the listener port

The listener needs to receive an inbound HTTP request from your forge. Do not open its port to the public internet with no other control. Pick one:

- **Front it with TLS and a secret path.** Put the listener behind a reverse proxy that terminates HTTPS (the same proxy that serves your app), and expose it at an unguessable path such as `/hooks/deploy-a1b2c3`. Open only 443 in the security group.
- **Restrict the source.** If your forge publishes its outbound webhook address range, add a security group rule that allows the listener port only from that range. Self-hosted Forgejo on a VM you operate has a fixed source address you can pin.

Whichever you choose, the signature check in step 3 is still required. Port scoping reduces exposure; it does not authenticate the caller.

## Step 2: Write an idempotent deploy script

The script runs on each verified push and must converge to the same result whether it runs once or ten times. For a containerized app, pull the new image by tag and recreate the container:

```bash
#!/usr/bin/env bash
set -euo pipefail

APP_DIR=/opt/notes-app
IMAGE=registry.example.com/me/notes-app:latest

cd "$APP_DIR"
docker pull "$IMAGE"
# Recreate only if the running container is not already on the pulled image.
docker compose up -d --no-deps app
docker image prune -f
```

For a `systemd`-managed service built from source, fast-forward the checkout and restart:

```bash
#!/usr/bin/env bash
set -euo pipefail

APP_DIR=/opt/notes-app
cd "$APP_DIR"
git fetch --quiet origin main
git reset --hard origin/main
npm ci --omit=dev
npm run build
sudo systemctl restart notes-app
```

Both scripts are safe to re-run: they reset to a known state rather than applying a diff. Save the script as `/opt/notes-app/deploy.sh` and make it executable with `chmod +x`.

## Step 3: Verify the signature

Configure the `webhook` tool to run the deploy script only when the request carries a valid signature. Create `/etc/webhook/hooks.json`. This example matches a GitHub push and checks the `X-Hub-Signature-256` HMAC against a shared secret read from the environment:

```json
[
  {
    "id": "deploy",
    "execute-command": "/opt/notes-app/deploy.sh",
    "command-working-directory": "/opt/notes-app",
    "trigger-rule": {
      "and": [
        {
          "match": {
            "type": "payload-hmac-sha256",
            "secret": "{{ getenv \"WEBHOOK_SECRET\" | js }}",
            "parameter": { "source": "header", "name": "X-Hub-Signature-256" }
          }
        },
        {
          "match": {
            "type": "value",
            "value": "refs/heads/main",
            "parameter": { "source": "payload", "name": "ref" }
          }
        }
      ]
    }
  }
]
```

The HMAC rule rejects any request whose body was not signed with `WEBHOOK_SECRET`, so an unauthenticated caller cannot trigger a deploy. The second rule restricts deploys to pushes on `main`. GitLab signs with a plain `X-Gitlab-Token` header (use a `value` match against `getenv`); Forgejo and Gitea use the same HMAC scheme as GitHub.

## Step 4: Run the listener as a service

Run `webhook` under `systemd` so it restarts on reboot and reads the secret from a unit environment file, not from `hooks.json`:

```ini
# /etc/systemd/system/webhook.service
[Unit]
Description=git push deploy webhook
After=network-online.target

[Service]
EnvironmentFile=/etc/webhook/webhook.env
ExecStart=/usr/local/bin/webhook -hooks /etc/webhook/hooks.json -template -port 9000 -verbose
Restart=on-failure
User=deploy

[Install]
WantedBy=multi-user.target
```

The `-template` flag tells `webhook` to expand `{{ getenv "WEBHOOK_SECRET" | js }}` in `hooks.json` from the environment at startup, so the secret stays out of the hooks file. Put the secret in `/etc/webhook/webhook.env` (`WEBHOOK_SECRET=...`), readable only by the `deploy` user (file mode `600`). Enable and start it:

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now webhook
```

## Step 5: Register the webhook in your forge

In the repository settings, add a webhook:

- **Payload URL**: your public endpoint, for example `https://notes.example.com/hooks/deploy` if you fronted the listener with a proxy, or `http://YOUR_FLOATING_IP:9000/hooks/deploy` if you scoped the port by source.
- **Secret**: the same value as `WEBHOOK_SECRET`.
- **Events**: push events only.
- **Content type**: `application/json`.

## Step 6: Verify

Push a commit to `main`, then watch the listener handle it on the VM:

```bash
sudo journalctl -u webhook -f
```

You see the hook match, the signature pass, and the deploy script run. Confirm the app picked up the change:

```bash
curl -fsS https://notes.example.com/healthz
```

Send a request with a wrong or missing signature and confirm the listener rejects it without running the script. That negative check is the one that proves the endpoint is not an open trigger.

## Deploy through a quake.yaml packet instead

The same listener can run the [launch handoff packet](/resources/ai-assisted-development/launch) rather than a hand-written pull-and-restart, so a push deploys from your [`quake.yaml`](/resources/ai-assisted-development/quake-yaml) without a CI server. Point `execute-command` at a script that prepares and applies the packet:

```bash
#!/usr/bin/env bash
set -euo pipefail
cd /opt/notes-app
git fetch --quiet origin main && git reset --hard origin/main

# Prepare the packet from the manifest in the repo.
yq -o=json '.' quake.yaml > manifest.json
jq -n --slurpfile m manifest.json \
  '{jsonrpc:"2.0",id:1,method:"tools/call",
    params:{name:"prepare_launch",arguments:{manifest:$m[0],action:"deploy"}}}' \
  > prepare.json
curl -sS https://docs.quake.ai/api/mcp -H 'Content-Type: application/json' \
  -d @prepare.json | jq '.result.structuredContent.content' > handoff.json

# Run a consumer of the packet: the Coolify consumer here, or tofu apply of
# the resolved template plan. Settings and secrets come from the environment.
python3 consumers/coolify/coolify_launch_consumer.py \
  --packet handoff.json \
  --git-repository https://github.com/me/notes-app
```

This is an executor of the existing packet, not a new manifest format. For the CI version of the same packet flow, see [How to execute a launch handoff packet from CI](/docs/automation/how-to/execute-launch-handoff-from-ci); for the full Coolify walkthrough, see [Deploy to Coolify from a quake.yaml manifest](/resources/deployments/deploy-quake-yaml-to-coolify).

## Security checklist

- The signature check (step 3) is mandatory. Without it the endpoint is an unauthenticated deploy trigger.
- Keep the secret in the unit environment file, not in `hooks.json` and not in the repository.
- Serve the endpoint over TLS so the secret and payload are not sent in clear text.
- Scope the listener port (step 1) so it is reachable only over the path you intend.
- Restrict deploys to the branch you expect with a `ref` match.

## Next steps

- [How to deploy an application to a Quake AI VM from CI](/docs/automation/how-to/app-cicd-vm): move to a pipeline when you need tests and build stages
- [How to self-host a CI server](/docs/automation/how-to/self-host-ci-server): run your own runner when one host is not enough
- [How to execute a launch handoff packet from CI](/docs/automation/how-to/execute-launch-handoff-from-ci): the `quake.yaml` packet flow under a CI provider
- [CI/CD pipelines](/resources/solutions/cicd-pipelines): the end-to-end build-and-deploy pattern
