# How to deploy on push with a GitHub Actions webhook

![Diagram: git push, then test and build, then a signed POST to Hubfly space](https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/11/3fcaf79e-a017-4ca4-bd4b-a40540826d47.png align="center")

Deploy on push with GitHub Actions: every merge to `main` goes live on its own, but only after your tests pass. Clicking Redeploy by hand is slow, and it's how an untested build reaches users on a Friday evening. In this guide you'll connect GitHub Actions to Hubfly space with a signed deploy webhook, stop two deploys from colliding, and set up a rollback to any previous image tag.

[Try it on Hubfly space](https://dashboard.hubfly.space/?ref=deploy-on-push-github-actions-webhook): 360 free hours a month on the shared tier (a one-time $5 deposit, kept as credit, unlocks creating resources).

## What a deploy webhook does

A deploy webhook is an address your CI calls when a build is ready; Hubfly space then performs one action on your container, such as pulling a new image. A registry push never redeploys anything by itself, so the webhook is the missing "now ship it" step.

Hubfly space offers three security modes:

| Mode | How the call is checked | Actions you can allow |
| --- | --- | --- |
| Basic | Token inside the URL | Image Pull only |
| Standard | Token inside the URL | Image Pull, Provisioning, Hard Restart |
| HMAC-SHA256 | Signed timestamp + body, no token in the URL | All, including Config Update and Rollback Recovery |

Use HMAC-SHA256. With Basic or Standard, anyone who sees the URL in a leaked build log can deploy you. With HMAC-SHA256 the URL alone is useless: each request is signed, and signatures older than five minutes are refused, so a captured request stops working after that window.

## Step 1: Create the webhook

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

1. Name it, for example `GitHub Actions deploy`.
2. Under **Target Container Context**, pick the one container this pipeline deploys, such as `shop-web`. Binding it to one container means a leaked secret can't touch anything else.
3. Click **Next Step**.

![Step 1 of Create deployment webhook: name, description and the target container](https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/11/c298f30a-a1a1-4989-ae8b-7ec60dc881a5.png align="center")

4. Choose **HMAC-SHA256**.
5. Under **Allowed Operation Permissions**, keep **Image Pull** on. Leave **Deprovisioning** off: a deploy pipeline never needs to delete anything.
6. Optional: if your CI runs on a machine with a fixed public IP (a self-hosted runner or build server), add it under **IP Allowlist Restriction**.
7. Click **Register Webhook**.

![HMAC-SHA256 selected, with Image Pull allowed](https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/11/edd86c20-cde7-411d-9454-f246dfd2ea0a.png align="center")

A dialog shows the **HMAC Signing Secret Key** and the **Deployment Pipeline URL Target**. Copy both now: the secret is shown once, and webhooks can't be edited later.

## 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 (it looks like `https://api.hubfly.space/api/webhooks/deploy/whk_…`)
- `HUBFLY_WEBHOOK_SECRET`: the signing secret

GitHub masks secrets in logs, so the key is hidden in build output.

## Step 3: Deploy on push with a GitHub Actions webhook

Save this as `.github/workflows/deploy.yml`. It runs on `ubuntu-latest` (OpenSSL 3, curl 7.76 or newer for `--fail-with-body`) and targets an image-based container. The `build` job pushes `ghcr.io/your-org/shop-web:sha-<first 7 characters of the commit>`; adapt it to your Dockerfile and registry, or keep your own build job and name it `build`. Hubfly space must be able to pull the image: GitHub makes new GHCR packages private, so either make the package public (**Package settings → Change visibility**) or set **Registry Authentication** when you create the container, with a token that can read it.

```yaml
name: Deploy to Hubfly space
on:
  push:
    branches: [main]
  workflow_dispatch:
    inputs:
      tag:
        description: "Image tag to deploy (for a rollback, the previous sha- tag)"
        required: false

concurrency:
  group: hubfly-deploy-production
  cancel-in-progress: false

jobs:
  test:
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "run your tests here"

  build:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - id: vars
        run: echo "tag=sha-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/your-org/shop-web:${{ steps.vars.outputs.tag }}

  deploy:
    needs: [test, build]
    if: always() && ((needs.build.result == 'success') || github.event_name == 'workflow_dispatch')
    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 }}
          IMAGE: ghcr.io/your-org/shop-web
          INPUT_TAG: ${{ inputs.tag }}
        run: |
          tag="${INPUT_TAG:-sha-${GITHUB_SHA::7}}"
          body=$(printf '{"action":"pull","image":"%s:%s"}' "$IMAGE" "$tag")
          timestamp=$(date +%s)
          signature=$(printf '%s.%s' "$timestamp" "$body" \
            | openssl dgst -sha256 -hmac "$HUBFLY_WEBHOOK_SECRET" -hex | sed 's/^.* //')
          curl --fail-with-body --max-time 120 -sS -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 the important lines do:

- `concurrency` queues deploys instead of running two at once. Without it, two quick pushes send overlapping calls: the second call returns a 500 even though its deploy may still go through, so the job's result no longer matches what's live.
- `tag` is the commit's short SHA, or the tag you type when you run the workflow by hand. Immutable tags make rollbacks exact.
- `signature` is an HMAC-SHA256 of `timestamp.body` (with the dot), which Hubfly space recomputes and compares.
- `--data-binary` sends the body byte for byte. Change one space after signing and the signature no longer matches.
- `--max-time 120`: the call waits until the deploy finishes, which takes roughly 20 to 30 seconds for a small image, then answers with `{"ok":true,"data":{"success":true,"deploymentId":"dep_…"}}`.
- `--fail-with-body` fails the job, and prints why, if Hubfly space refuses the call.

For a Git-backed container, the docs give `{"action":"pull","ref":"main","commitSha":"<sha>"}` as the body instead. On a self-hosted runner, `openssl -hmac` shows the secret in the process list, so give the runner its own user.

## Step 4: Watch deploys, and roll back

Every call appears under **Pipeline → Recent deployments**; expand a row to see the exact image it deployed.

![The webhook card and Recent deployments, with the image each deploy used](https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/11/203bc670-1609-49ab-b4dd-7828edfed682.png align="center")

To roll back, open **Actions → Deploy to Hubfly space → Run workflow** in GitHub, type the previous tag (copy it from the deployment row you want back, such as `sha-1a2b3c4`) and run it. That pulls the old image through the same signed path.

Hubfly space also has a **Rollback Recovery** action (`{"action":"rollback"}`). After an image pull, though, it keeps the image you just pulled instead of switching back to the previous tag, so don't rely on it to undo a bad image. Redeploying the previous tag is the dependable way back.

## What can go wrong

| You see | Likely cause | Fix |
| --- | --- | --- |
| `500 Unexpected server error` in about a second | The call was refused: bad or missing signature, a timestamp more than five minutes off, a body changed after signing, or an action the webhook doesn't allow. No deployment is created | Sign `timestamp.body` and send the same bytes with `--data-binary`. Check the runner's clock and the webhook's allowed actions |
| `500` while another deploy is running | Two calls overlapped; the deploy may still have run | Keep the `concurrency` block, then check Recent deployments for what actually ran |
| `curl: (28)` timeout | The deploy took longer than `--max-time` | Raise it; check Recent deployments for the real result |
| Image pushed, nothing changed | A registry push never redeploys by itself | Call the webhook with a `pull` action |
| "Container is missing" | Project-scoped webhook called without `containerId` | Bind the webhook to one container, or add `"containerId"` to the body |

The docs list 400, 401 and 403 for refused calls; today they return 500, so treat a fast 500 as "refused".

If you delete a webhook's container, the webhook stays active as project-scoped (**Bound to: All Containers**). Delete it too.

## Limits and pricing

- **Deploy webhooks are free.** There's no charge per webhook or per call.
- **You pay for the container.** The shared tier (0.3 vCPU, 256 MB) is free for 360 runtime hours a month. A dedicated container with 1 vCPU, 512 MB and 5 GB of disk running all month costs 1 × $0.004 × 730 + 0.5 × $0.002 × 730 + 5 × $0.00001536 × 730 = $2.92 + $0.73 + $0.06 = **$3.71 a month**, less with Auto Sleep on.
- **New accounts:** creating resources needs a one-time deposit of $5 or more. It stays as account credit; it isn't a fee.
- **One action per request**, only the actions you allowed, and signatures expire after five minutes.

## Who this is not for

- **Teams on GitHub-hosted runners who need an IP allowlist.** Those runners have no fixed address.
- **Anyone without CI.** Point the container at a Git repository and redeploy from the dashboard.

## FAQ

**Why does my signed webhook request fail?**\
Almost always the body sent differs from the body signed, or the runner's clock is off by more than five minutes. Use `--data-binary "$body"` with the same variable you signed.

**How do I stop two GitHub Actions deploys from running at the same time?**\
Add a `concurrency` group with `cancel-in-progress: false`. A running deploy finishes first; the newest waiting run goes next, and GitHub cancels older pending runs in the same group.

**How do I roll back a deployment from GitHub Actions?**\
Run the workflow by hand with the previous image tag. Immutable `sha-` tags make this exact.

**Can I put the webhook URL in my repository?**\
No. Store the URL and the secret as encrypted repository secrets.

## Read next

- [Docker health checks that catch real problems](https://blog.hubfly.space/docker-health-checks-postgres-redis?utm_source=hashnode&utm_medium=crosspost&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_medium=crosspost&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_medium=crosspost&utm_campaign=deploy-on-push-github-actions-webhook)
- [How to deploy a Go app with Docker using a tiny image](https://blog.hubfly.space/deploy-go-app-docker?utm_source=hashnode&utm_medium=crosspost&utm_campaign=deploy-on-push-github-actions-webhook)

## Go further on Hubfly space

- **Catch bad releases sooner** with a container health check: [observability docs](https://www.hubfly.space/docs/observability/?ref=deploy-on-push-github-actions-webhook).
- **Send 10% of traffic to the new version first** with a weighted load balancer: [load balancer docs](https://www.hubfly.space/docs/load-balancers/?ref=deploy-on-push-github-actions-webhook).
- **Get notified when an image is pushed or scanned** with registry webhooks: [webhooks docs](https://www.hubfly.space/docs/webhooks/?ref=deploy-on-push-github-actions-webhook).

**Stop redeploying by hand.** [Try it on Hubfly space](https://dashboard.hubfly.space/?ref=deploy-on-push-github-actions-webhook): 360 free hours a month on the shared tier, after a one-time $5 deposit kept as credit.

---

*By Octave · drafted with AI, tested on Hubfly · Last updated 9 November 2026*
