How to deploy on every push with GitHub Actions and a webhook
Originally published on the Hubfly space blog.
TL;DR
- What you get: every push to
mainredeploys 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 →
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.
Give it a clear Webhook name, such as
GitHub Actions deploy, and an optional description.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.
Click Next Step.
Choose HMAC-SHA256.
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.
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.
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 likehttps://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.
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:
bodyis 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.timestampis the current Unix time. It's part of the signature, which is what stops old requests from being replayed.signatureis an HMAC-SHA256 oftimestamp.body, with the dot. Hubfly space computes the same value with your secret and compares.--data-binarysends the body byte for byte. Change a single space after signing and the signature no longer matches.--fail-with-bodymakes 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:
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
- Canary releases with a weighted load balancer
- Gate your app on a database migration job
Go further on Hubfly space
- Add a health check so a failing release can be caught and rolled back: see the observability docs.
- Send 10% of traffic to the new version first with a weighted load balancer: load balancer docs.
- Get notified when an image is pushed or scanned with registry webhooks: webhooks docs.
Ready to stop redeploying by hand? No card needed: Start free, 360 hours included →
Comments
Join the discussionNo comments yet. Be the first to comment.