# How to deploy on every push with GitHub Actions and a webhook

*Originally published on the [Hubfly space blog](https://blog.hubfly.space/deploy-on-push-github-actions-webhook?utm_source=hashnode&utm_campaign=deploy-on-push-github-actions-webhook).*

> **TL;DR**
>
> - **What you get:** every push to `main` redeploys your container on Hubfly space, and you can send a rollback action when a release goes bad.
> - **Who it's for:** developers who build images or test code in GitHub Actions (or GitLab CI) and still redeploy by hand.
> - **Time:** about 15 minutes.
> - **Cost:** deploy webhooks are free. You pay only for the container itself; the shared tier is free for 360 hours a month.

Your app is live, but every change still ends with you opening the dashboard and clicking Redeploy. That's slow, and it's how the wrong build reaches your users on a Friday evening. In this guide you'll connect GitHub Actions to Hubfly space with a signed deploy webhook, so every push to `main` goes live on its own once your tests pass, and you can send a rollback when a release goes bad.

[Try it on Hubfly space. Start free, 360 hours included →](https://dashboard.hubfly.space)

## What a deploy webhook does

A deploy webhook is an address your CI calls when a build is ready. Hubfly space then does one action on your container: pull the new image or commit, update its settings, restart it, or roll it back. Pushing an image to a registry never redeploys anything by itself, so the webhook is the missing "now ship it" step.

Each webhook has three settings that matter:


| Setting             | What it controls                   | Pick                                    |
| ------------------- | ---------------------------------- | --------------------------------------- |
| **Security mode**   | How Hubfly space checks the caller | HMAC-SHA256 for anything real           |
| **Allowed actions** | What the caller may do             | Only what your pipeline needs           |
| **IP allowlist**    | Where calls may come from          | Your fixed runner IPs, if you have them |


The three security modes differ a lot:


| Mode                 | How the call is checked                      | Actions the dashboard offers                                |
| -------------------- | -------------------------------------------- | ----------------------------------------------------------- |
| Basic                | Token inside the URL                         | Image Pull only                                             |
| Standard             | Token inside the URL                         | Image Pull, Provisioning, Hard Restart                      |
| HMAC-SHA256 (secure) | Signed timestamp + body, no token in the URL | Every action, including Config Update and Rollback Recovery |


Why it matters: with Basic or Standard, anyone who sees the URL (a leaked build log is enough) can trigger it. With HMAC-SHA256 the URL alone is useless. Each request must be signed with a secret, and the signature expires after five minutes, so a captured request can't be replayed later.

## Step 1: Create the webhook

Open your project, then the **Pipeline** tab, and click **Create webhook**.

1. Give it a clear **Webhook name**, such as `GitHub Actions deploy`, and an optional description.
2. Under **Target Container Context**, pick the container this webhook may deploy. Leave it on **All Containers (Project Scope)** only if one pipeline deploys several services; then every request must say which container it means.
3. Click **Next Step**.

4. Choose **HMAC-SHA256**.
5. Under **Allowed Operation Permissions**, turn on **Image Pull** and **Rollback Recovery**. Add **Config Update** only if your pipeline also changes environment variables. Leave **Deprovisioning** off: a deploy pipeline never needs to delete anything.
6. If your CI runs on a machine with a fixed public IP (a self-hosted runner, a build server), enter it under **IP Allowlist Restriction**.
7. Click **Register Webhook**.

A dialog then shows the **HMAC Signing Secret Key** and the **Deployment Pipeline URL Target**. The secret is shown once. Copy both now; if you lose the secret, delete the webhook and create a new one. Webhooks can't be edited after you create them, so changing the allowed actions also means creating a new one.

## Step 2: Store the values in GitHub

In your repository, open **Settings → Secrets and variables → Actions** and add two repository secrets:

- `HUBFLY_WEBHOOK_URL`: the URL you copied (it looks like `https://api.hubfly.space/api/webhooks/deploy/whk_…`).
- `HUBFLY_WEBHOOK_SECRET`: the signing secret.

Secrets are masked in logs, which keeps the signing key out of your build output.

## Step 3: Sign and send the request

Add this workflow as `.github/workflows/deploy.yml`. The `deploy` job waits for `test`, so a failed test never reaches production. Replace the test step with your own.

```yaml
name: Deploy to Hubfly space
on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "run your tests here"

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - name: Trigger Hubfly space deployment
        env:
          HUBFLY_WEBHOOK_URL: ${{ secrets.HUBFLY_WEBHOOK_URL }}
          HUBFLY_WEBHOOK_SECRET: ${{ secrets.HUBFLY_WEBHOOK_SECRET }}
          COMMIT_SHA: ${{ github.sha }}
        run: |
          body=$(printf '{"action":"pull","ref":"main","commitSha":"%s"}' "$COMMIT_SHA")
          timestamp=$(date +%s)
          signature=$(printf '%s.%s' "$timestamp" "$body" \
            | openssl dgst -sha256 -hmac "$HUBFLY_WEBHOOK_SECRET" -hex | sed 's/^.* //')
          curl --fail-with-body -X POST "$HUBFLY_WEBHOOK_URL" \
            -H 'Content-Type: application/json' \
            -H "X-Hub-Timestamp: $timestamp" \
            -H "X-Hub-Signature-256: sha256=$signature" \
            --data-binary "$body"
```

What each line does, in plain words:

- `body` is the exact JSON you send. For a Git-backed container it names the branch and commit. For an image-based container, send `{"action":"pull","image":"ghcr.io/your-org/your-app:sha-7f3b1a9"}` instead.
- `timestamp` is the current Unix time. It's part of the signature, which is what stops old requests from being replayed.
- `signature` is an HMAC-SHA256 of `timestamp.body`, with the dot. Hubfly space computes the same value with your secret and compares.
- `--data-binary` sends the body byte for byte. Change a single space after signing and the signature no longer matches.
- `--fail-with-body` makes the job fail, and print the reason, if Hubfly space refuses the call.

On a self-hosted runner, keep in mind that `openssl -hmac` puts the secret on the command line, where other users on that machine could see it in the process list. Use a dedicated runner user.

For a project-scoped webhook, add `"containerId":"<your container ID>"` to the body.

Deploy an immutable tag (`sha-7f3b1a9`) or a commit SHA rather than `latest`. Then you always know which build is running, and a rollback has something specific to go back to.

## Step 4: Watch it, and roll back when needed

Each call shows up under **Pipeline → Recent deployments** with its action, trigger, status, duration and any error. The webhook card counts **Triggers** and shows the **Last Run**. You can also list the history with `GET /api/v1/projects/<project-id>/deployments`.

If a release is bad, send the rollback action with the same signing step:

```bash
body='{"action":"rollback"}'
```

Rollback is an explicit action: send it yourself, from a manual workflow or an incident script. When you send an `update`, the docs also list rollback options for it: `rollback.enabled`, `rollback.onHealthCheckFail` and a positive `rollback.timeoutSeconds`, meant to revert the update if the new version fails its health check. A rollback needs a previous version to go back to, and a health-check trigger needs a health check, so always open the deployment event to confirm what actually happened.

## What can go wrong


| You see                       | Why                                                                                   | Fix                                                                                           |
| ----------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `401`                         | Wrong or missing URL token (Basic/Standard)                                           | Copy the full URL again. Never add a token to a secure URL.                                   |
| `400` on a secure webhook     | Bad signature, a timestamp older than five minutes, or the body changed after signing | Sign `timestamp.body` and send the same bytes with `--data-binary`. Check the runner's clock. |
| `403`                         | The action isn't allowed, or the caller's IP isn't on the allowlist                   | Create a new webhook with the right actions or IPs.                                           |
| "Container is missing"        | Project-scoped webhook called without `containerId`                                   | Add `containerId` to the body.                                                                |
| Image pushed, nothing changed | A registry push never redeploys by itself                                             | Call the webhook with `{"action":"pull"}`.                                                    |


## Limits and pricing

- **Webhooks are free.** There's no charge per webhook or per call.
- **The container is what you pay for.** The shared tier (0.3 vCPU, 256 MB) is free for 360 runtime hours a month and sleeps when idle. A dedicated container with 1 vCPU and 512 MB running all month costs 1 × $0.004 × 730 + 0.5 × $0.002 × 730 = $2.92 + $0.73 = **$3.65 a month**.
- **Five-minute window** for secure signatures.
- **One action per request**, and only the actions you allowed when you created the webhook.
- **No edits:** to change a webhook's mode, actions or IPs, create a new one and delete the old one.

## Who this is not for

- **Teams on GitHub-hosted runners who want an IP allowlist.** Those runners don't have fixed addresses, so the allowlist only fits self-hosted runners or a fixed-IP build server. Use the signature alone, or move the deploy job to a self-hosted runner.
- **Anyone who wants Hubfly space to build and deploy on every push with no CI at all.** Connect the repo directly instead: Git deploys can deploy on push, or after CI succeeds.
- **Teams that want automatic rollback without health checks.** Automatic rollback on an update depends on a health check (`rollback.onHealthCheckFail`). Add one first.
- **Pipelines that need preview environments per pull request.** A webhook deploys to existing containers; it doesn't create a new environment for each branch.

## Read next

- [Docker health checks that catch real problems, explained simply](https://blog.hubfly.space/docker-health-checks-postgres-redis?utm_source=hashnode&utm_campaign=deploy-on-push-github-actions-webhook)
- [Canary releases with a weighted load balancer](https://blog.hubfly.space/canary-release-weighted-load-balancer?utm_source=hashnode&utm_campaign=deploy-on-push-github-actions-webhook)
- [Gate your app on a database migration job](https://blog.hubfly.space/docker-compose-migration-job-service-completed-successfully?utm_source=hashnode&utm_campaign=deploy-on-push-github-actions-webhook)

## Go further on Hubfly space

- **Add a health check** so a failing release can be caught and rolled back: see the [observability docs](https://www.hubfly.space/docs/observability/).
- **Send 10% of traffic to the new version first** with a weighted load balancer: [load balancer docs](https://www.hubfly.space/docs/load-balancers/).
- **Get notified when an image is pushed or scanned** with registry webhooks: [webhooks docs](https://www.hubfly.space/docs/webhooks/).

**Ready to stop redeploying by hand?** No card needed: [Start free, 360 hours included →](https://dashboard.hubfly.space)
