How to deploy on git push with a webhook listener
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 run internally.
Prerequisites
- A VM in your Quake AI project running the app, either as a
systemdservice or a container. See How to deploy an application to a Quake AI VM from CI for the target setup. - SSH access to that VM and
sudoto install a service. - A git repository on GitHub, GitLab, or Forgejo, with permission to add a webhook.
git(for the pull-and-restart flow) ordocker(for the image flow) on the VM.- The
webhooktool, 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.
How the flow works#
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:
#!/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 -fFor a systemd-managed service built from source, fast-forward the checkout and restart:
#!/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-appBoth 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:
[
{
"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:
# /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.targetThe -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:
sudo systemctl daemon-reload
sudo systemctl enable --now webhookStep 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/deployif you fronted the listener with a proxy, orhttp://YOUR_FLOATING_IP:9000/hooks/deployif 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:
sudo journalctl -u webhook -fYou see the hook match, the signature pass, and the deploy script run. Confirm the app picked up the change:
curl -fsS https://notes.example.com/healthzSend 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 rather than a hand-written pull-and-restart, so a push deploys from your quake.yaml without a CI server. Point execute-command at a script that prepares and applies the packet:
#!/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-appThis 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; for the full Coolify walkthrough, see Deploy to Coolify from a quake.yaml manifest.
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.jsonand 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
refmatch.
Next steps#
- How to deploy an application to a Quake AI VM from CI: move to a pipeline when you need tests and build stages
- How to self-host a CI server: run your own runner when one host is not enough
- How to execute a launch handoff packet from CI: the
quake.yamlpacket flow under a CI provider - CI/CD pipelines: the end-to-end build-and-deploy pattern
Usage Guidelines
The sample code, software libraries, command line tools, proofs of concept, templates, and other related technology on this page (including any of the foregoing that is provided by Quake AI personnel) is provided to you as Quake AI Content under the Quake AI Customer Agreement, or the relevant written agreement between you and Quake AI (whichever applies). Do not use this Quake AI Content in your production accounts, or on production or other critical data. You are responsible for testing, securing, and optimizing the Quake AI Content (such as sample code) as appropriate for production grade use based on your specific quality control practices and standards. Deploying Quake AI Content may incur Quake AI charges for creating or using Quake AI chargeable resources, such as running Compute instances or storing data in Object Storage. Your use is also subject to the Acceptable Use Policy.
For the full policy, see Usage Guidelines.
Last validated: 08.09.2026
See Also
How to execute a launch handoff packet from CI
Shares: Quake Yaml, Launch
How to deploy from a Quake.yaml manifest with Forgejo Actions
Shares: Quake Yaml, Launch
Deploy Coolify from a Quake.yaml manifest
Shares: Quake Yaml, Launch
Launch handoff
Shares: Quake Yaml, Launch
quake.yaml reference
Shares: Quake Yaml, Launch