$ aoox

DocsApplications

Webhook auto-deploy

Automatic deploy on every push to the application's branch, plus Git credentials for private repos.

Trigger
Push to the application's branch
Endpoint
POST <PUBLIC_API_URL>/webhooks/<token>
Security
URL token + optional HMAC secret

Prerequisites

  • PUBLIC_API_URL is reachable from the internet — GitHub/GitLab call it from outside. A public IP + port 3001, or a domain via Domain for the panel.
  • The application has already been deployed manually at least once (registry, build, etc. already set up).

Flow

  1. 01pushgit push origin main
  2. 02providerGitHub/GitLab calls the Webhook URL
  3. 03verifyURL token (+ signature if the secret is on)
  4. 04deployqueued just like the Deploy button

Git credentials (private repos)

Needed so the Docker daemon (and the Nixpacks helper) can clone a private repo. It's unrelated to incoming webhooks, but the two are usually set up together.

  1. Create a token at the provider

    ProviderToken typeMinimum scope
    GitHubFine-grained or classic PATClassic: repo. Fine-grained: Contents → Read.
    GitLabProject/personal access tokenread_repository
    GenericUsername + HTTP basic password/tokenRead access
  2. Integrations → Git credentials → New Git credential (owner/admin)

    Choose a Provider, fill in a Username and Password / token. The token is stored encrypted (ENCRYPTION_KEY) and never shown again.

  3. Select it in the application form

    Select Git credential in the application's Settings. The repo URL is still written without user:token@ — aoox assembles it at build time and redacts the token from logs.

  • Deleting a credential detaches it from any application using it; the next build fails if the repo is private.
  • For a GitHub fine-grained token, make sure the repo is included in the token's access list.

Enabling the webhook

  1. Copy the Webhook URL from the Webhook tab

    https://api.panel.example.com/webhooks/9f3a1c…e21d

    A random 32-byte token, unique per application. Regenerate it anytime — the old URL stops working immediately.

  2. Register it with the provider

    GitHub

    FieldValue
    Payload URLthe Webhook URL
    Content typeapplication/json
    Secretfill in if the secret is enabled (next step)
    EventsJust the push event; add Pull requests for previews

    GitLab

    FieldValue
    URLthe Webhook URL
    Secret tokenfill in if the secret is enabled
    TriggerPush events; add Merge request events for previews
  3. Test: push to the application's branch

    A new deployment appears on the Deploy tab a few seconds after the push. On the provider's side, the Recent Deliveries page shows the API's response — see the table below to read it.

    Without waiting on the provider, test straight from the server first — a ping event is always ignored, safe to use just to confirm the URL and token before touching GitHub/GitLab:

    curl -i -X POST https://<API_DOMAIN>/webhooks/<token> \
      -H "Content-Type: application/json" \
      -H "X-GitHub-Event: ping" \
      -d "{}"
    # should be 200 with body {"result":"ignored","reason":"event ping"}

Reading the webhook response

SituationStatusBody
Push to the application's branch200{ result: "queued" } — deploy queued
Push to another branch / non-push event / branch deleted200ignored + an explanatory reason
A deployment is already running200busy — not queued; push again once it's done
PR/MR opened, updated, closed (previews on)200preview / preview-closed
Secret is on but the signature is wrong/missing401Rejected
Unknown URL token404Rejected
More than 30 requests/minute429Throttled
busy means a push that lands while a build is already running is not queued. If your team pushes in quick succession, get in the habit of waiting for the deployment to finish, or push once more afterward.

Redeliver: retest without a new push

This is what actually fixes it, most of the time
After fixing the Payload URL, the secret, or something on the API/proxy side, don't just wait — the provider doesn't resend on its own. Resend the same delivery with Redeliver (GitHub) / Resend request (GitLab) to find out right away whether the fix worked, with no need for a new commit.

GitHub

  1. Repo → Settings → Webhooks

    Click the webhook aoox uses (Edit).

  2. Recent Deliveries tab

    Click the topmost (most recent) delivery.

  3. Redeliver button

    Then open the Response tab — it should say 200. The "Last delivery was not successful" status on the webhook list only updates after a new delivery (a redeliver or a fresh push); fixing the underlying cause alone doesn't clear that red status until something new is attempted.

GitLab

  1. Settings → Webhooks → Edit

    Open the Recent events section below the form.

  2. Resend request

    On the failed event's row. To trigger a fresh delivery with no old event at all: the Test button → pick Push events, no real push needed.

Secret (signature)

Without a secret, anyone who knows the URL can trigger a deploy (not change the code — just rebuild the same branch). To close that off, in the Secret (signature) section:

  1. Click enable, copy the secret value

    Shown on the Webhook tab for as long as it's enabled, so you can copy it again.

  2. Paste it into the provider

    • GitHub: the Secret field → header X-Hub-Signature-256 = sha256=HMAC-SHA256(secret, raw body).
    • GitLab: the Secret token field → header X-Gitlab-Token = the secret as-is.
  3. Redeliver the last delivery from the provider

    See Redeliver above. Should return 200. If it's 401, the secret at the provider doesn't match.

  • Rotating generates a new value — update it at the provider before the next push.
  • Disabling falls back to URL-token-only behavior.
  • The comparison uses timingSafeEqual; the HMAC is computed over the exact request body bytes, not the parsed JSON.
  • Optional extra layer for GitHub: put WEBHOOK_VERIFY_GITHUB_IP=true in .env.dist so a GitHub delivery for an app with a secret must also come from GitHub's published IP ranges (empty = off; don't enable it for GitHub Enterprise Server or GitLab). An install created before this variable was forwarded only gets it after aoox reinstall.

Monorepo: deploy only when the folder changed

An application with a Root directory (see Build types) has the switch Webhook: deploy only when files in this folder change. When it is on, a push that only touches other folders is answered ignored and no deployment starts. It is off by default, so the old behaviour (every push to the branch deploys) is unchanged.

  • aoox reads the per-commit file lists in GitHub and GitLab push payloads (added, modified, removed).
  • When it cannot tell, aoox deploys anyway. That covers a payload without file lists, a commit without file lists (merge commits on GitHub), pushes of 20 or more commits (the payload cap of both providers), force pushes, and new branches.
  • Changes outside the folder the build uses (for example packages/shared) do not trigger a deploy. Turn the switch off for applications that depend on files outside their folder.
  • It applies to pushes to the application branch; pull request events for previews are not filtered.

Common pitfalls

SymptomCause & fix
Provider: connection refused / timeoutPUBLIC_API_URL isn't reachable from the internet, or port 3001 is blocked by a firewall.
200 ignored even though the push is to the right branchThe branch in the application form differs (e.g. master vs main), or the event sent isn't a push.
Always 401 after enabling the secretGitHub's content type isn't application/json, or the secret hasn't been pasted/differs.
Deploy runs but the build fails: repository not foundThe repo is private with no Git credential set, or the token expired.
The Webhook tab still shows localhost:3001 even though the panel already has a domainOn older installs, PUBLIC_API_URL used to only be passed to the web container, not api. Add PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3001} to the api service's environment: block in docker-compose.dist.yml, then docker compose -f docker-compose.dist.yml -f docker-compose.override.yml --env-file .env.dist up -d. aoox update panel never rewrites the compose file, so older installs need this manual step once.
GitHub: Invalid HTTP Response: 404The Payload URL uses the dashboard/web domain (e.g. panel.example.com/webhooks/…) instead of the API domain — the webhook endpoint lives on the API; the web app (Next.js) is what answers with 404. Use https://<API_DOMAIN>/webhooks/<token> (copy it from the Webhook tab), not the panel's domain.
“Last delivery was not successful” stays red after a fixThat status only updates after a new delivery — see Redeliver: retest without a new push.
Telling the two kinds of 404 apartA plain-text 404 page not found body means Traefik rejected it (the API domain's router doesn't match, or that domain never reaches the API container); a JSON {"statusCode":404,...} body means it reached the API but the token in the URL isn't recognized (an old token after "Generate new URL", or a copy-paste mistake).
Not available yet
Provider IP verification; queuing deploys while busy; webhooks for compose stacks.

Next steps

Found a mistake? Edit this page on GitHub ↗