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_URLis 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
- 01pushgit push origin main
- 02providerGitHub/GitLab calls the Webhook URL
- 03verifyURL token (+ signature if the secret is on)
- 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.
Create a token at the provider
Provider Token type Minimum scope GitHub Fine-grained or classic PAT Classic: repo. Fine-grained: Contents → Read.GitLab Project/personal access token read_repositoryGeneric Username + HTTP basic password/token Read access 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.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
Copy the Webhook URL from the Webhook tab
https://api.panel.example.com/webhooks/9f3a1c…e21dA random 32-byte token, unique per application. Regenerate it anytime — the old URL stops working immediately.
Register it with the provider
GitHub
Field Value Payload URL the Webhook URL Content type application/jsonSecret fill in if the secret is enabled (next step) Events Just the push event; add Pull requests for previews GitLab
Field Value URL the Webhook URL Secret token fill in if the secret is enabled Trigger Push events; add Merge request events for previews 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
pingevent is alwaysignored, 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
| Situation | Status | Body |
|---|---|---|
| Push to the application's branch | 200 | { result: "queued" } — deploy queued |
| Push to another branch / non-push event / branch deleted | 200 | ignored + an explanatory reason |
| A deployment is already running | 200 | busy — not queued; push again once it's done |
| PR/MR opened, updated, closed (previews on) | 200 | preview / preview-closed |
| Secret is on but the signature is wrong/missing | 401 | Rejected |
| Unknown URL token | 404 | Rejected |
| More than 30 requests/minute | 429 | Throttled |
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
GitHub
Repo → Settings → Webhooks
Click the webhook aoox uses (Edit).
Recent Deliveries tab
Click the topmost (most recent) delivery.
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
Settings → Webhooks → Edit
Open the Recent events section below the form.
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:
Click enable, copy the secret value
Shown on the Webhook tab for as long as it's enabled, so you can copy it again.
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.
- GitHub: the Secret field → header
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=truein.env.distso 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 afteraoox 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
| Symptom | Cause & fix |
|---|---|
| Provider: connection refused / timeout | PUBLIC_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 branch | The branch in the application form differs (e.g. master vs main), or the event sent isn't a push. |
| Always 401 after enabling the secret | GitHub's content type isn't application/json, or the secret hasn't been pasted/differs. |
| Deploy runs but the build fails: repository not found | The 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 domain | On 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: 404 | The 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 fix | That status only updates after a new delivery — see Redeliver: retest without a new push. |
| Telling the two kinds of 404 apart | A 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). |
busy; webhooks for compose stacks.Next steps
Found a mistake? Edit this page on GitHub ↗