Skip to main content
GitHubGitLabBitbucketGitea
Connect a git provider once for the whole control plane, then point any number of apps at repositories it can see. A push, a tag, or a pull request on the repository then deploys the app, builds a preview, or both. This page covers connecting a provider, using a repository as an app’s source, deploy triggers and path filters, deploying a commit by hand, monorepo build settings, webhook handling and replay, and per-pull-request previews.
Fastest path: connect the provider in the dashboard (Connect a provider), then run levelrail-cli github-app use-as-source <owner> <repo> --app-name my-app. Pushes to the default branch deploy from then on.

Provider connections and git sources

Two records are involved, and keeping them separate is deliberate.
  • A provider connection (a GitHub App, GitLab OAuth Application, Bitbucket OAuth consumer, or Gitea OAuth2 Application) is a control-plane-wide credential that can see many repositories across many organizations or workspaces. You connect it once.
  • An app’s git source (GET/PUT/DELETE /api/v1/apps/{name}/git-source) is a per-app record: one repository URL, one branch, one build configuration, one webhook secret, and one deploy trigger mode. Each app has its own webhook secret and its own enable and disable state.
So you connect GitHub once, wire ten apps to ten different repositories, and never paste a personal token into an app by hand.

Provider differences

All four support webhooks, preview environments, and PR comments and commit statuses. GitHub’s manifest flow creates the App on GitHub’s side for you. GitLab, Bitbucket, and Gitea need you to create the application in their own settings first and paste its credentials in. GET /api/v1/git-providers (and levelrail-cli git-providers) answers “what can I do with each provider?” in one call: whether it is connected, can list branches, can register a webhook, and can authenticate a clone. The dashboard’s repository picker uses it.

Connect a provider

Connecting is done in the dashboard on purpose. Each flow ends in a real browser redirect to github.com, your GitLab instance, or bitbucket.org, which a script or the CLI cannot drive. github-app, gitlab-app, bitbucket-app and gitea-app with no subcommand only print usage.
All four need a master key configured on the control plane, the same one that encrypts every secret. Routes that need it return 501 without one. GitHub’s manifest flow also needs a primary domain set in ingress settings first, because GitHub needs a reachable callback URL. Without one you get a 409 that says so.
Open the GitHub App settings page (/settings/github-app).
1

Choose the owner

The dashboard previews the App’s name, permissions, and webhook URL and lets you choose who owns the App.“Allow other accounts and organizations to install it” makes the App public (public=true). Leave it off for a private App, which GitHub only lets the owning account or organization install.
2

Create the App on GitHub

The dashboard redirects your browser to github.com, and the credentials come back automatically. levelrail-cli github-app register-url --owner <org> [--public] prints the registration link for the same choice. Open it in a browser signed in to the dashboard.
3

Install it

Levelrail sends you to /apps/<slug>/installations/new, GitHub’s account chooser. Pick the account or organization and the repositories to grant. You need to be an owner of the organization, or its owners must approve the install request. Use “Add organization” in the connected accounts list to install the same App on another account later.
A private App owned by your personal account never offers an organization on the install page. To deploy from an organization, either own the App from that organization or make the App public. Levelrail asks GitHub whether the App is public; when it is private, the dashboard and github-app installations add say so and link to the App’s Advanced settings, where “Make public” allows installs on any account.
GitHub Enterprise Server works the same way: set instance_url and the form posts to <instance_url>/organizations/<login>/settings/apps/new. owner must be a GitHub organization login (letters, digits and hyphens, up to 39 characters); anything else is a 400.If the control plane has no publicly reachable primary domain yet, create the App at github.com/settings/apps yourself and connect it with PUT /api/v1/github-app/manual, giving the app ID, client ID, client secret, webhook secret, and the private key PEM.
To disconnect, call DELETE /api/v1/github-app (or /gitlab-app, /bitbucket-app, /gitea-app) removes the connection and its secrets from the control plane only. It does not revoke the token or delete the App on the provider’s side. Remove it there yourself, for example from github.com/settings/apps.

Use a repository as an app’s source

Once a provider is connected, the CLI can list repositories and branches and connect a repository to an app. use-as-source also registers the webhook in the same call.
use-as-source takes the same optional settings as PUT /api/v1/apps/{name}/git-source:
If webhook registration fails, GitHub still connects the repository and reports webhook_registered: false with a webhook_error (typically an installation that predates the repository_hooks:write permission). GitLab, Bitbucket, and Gitea fail the whole request with a 502 instead. For an app that already exists, levelrail-cli apps git-source get|set|delete <name> manages the git source directly.

Deploy triggers: push or release

Every git source has a trigger_mode. It is independent of preview environments and is not part of app.yaml. Switching modes takes effect on the next webhook and never touches what is currently deployed. Set it from the Deploy trigger select on the app’s git source card, or from the CLI:
How each provider handles release mode:

Path filters

A git source can deploy only when a push changes files you ship:
--paths and --paths-ignore take comma-separated doublestar globs (** spans directories, dotfiles match, commas inside {a,b} do not split) and replace the whole list. An empty value clears it. Tag pushes are never path filtered. A push whose changed files all fall outside the filter is answered with ignored: skipped: no changed path matched paths (or ... matched paths_ignore) and shows in the delivery history. The file list comes from the push payload. When the payload has none (Bitbucket, or a push of more than 20 commits) the control plane reads it from the provider, and if that fails the push deploys anyway. The dashboard has the same fields on the app’s Source tab under Deploy filters and status reporting.

Reporting back to the provider

With report_status on (the default), the control plane reports to the provider:
  • Pipeline runs get a commit status per run, linking to the run page. See Pipelines.
  • GitHub deploys get a Deployments API entry for each push deploy (environment production) and each pull request preview (environment preview), moving through in_progress to success or failure. A newer successful deploy marks earlier ones in the same environment inactive, a partially failed multi-service deploy is failure, a superseded deploy is inactive, and closing a pull request marks its preview deployment inactive. A deployment the provider refuses to create is kept as an error row with the reason.
GitHub Apps registered recently request statuses: write and deployments: write. An App registered earlier needs those permissions added and accepted in GitHub, otherwise posts fail with a warning and nothing else changes. A failed post never fails a deploy or run. Turn reporting off for one app with levelrail-cli apps git-source settings my-app --report-status=false, or for the whole server with APP_GIT_STATUS_ENABLED=false. GitHub merge_group events route to pipelines with a merge_group trigger; subscribe the repository webhook to the “Merge groups” event.

Deploying by hand

A push deploys on its own, but you can also deploy any commit yourself. The Deploy button on an app opens a sheet that adapts to what the app deploys from.
Git source connected. The sheet leads with “Deploy latest commit on <branch>”: the head commit’s SHA, message, author and age, with a one-click Deploy latest commit button.
  • Below it are the ten most recent commits, each with its own Deploy and a badge for live, failed or building.
  • A branch switcher and the build it will run (build pack, Dockerfile path, build directory) follow.
  • A status line says whether what is live is up to date or how many commits behind the head it is.
  • Redeploy last successful and Roll back to previous appear once the app has history.
  • After you click Deploy the sheet closes and opens the live build log.
Image only. An image form pre-filled with the current image and your recent tags as one-click chips. The “fresh registry lookup” option lives under Advanced.Nothing connected. A guided choice between connecting a repository and deploying an image.
Deploying a different branch than the connected one builds that branch once. Push webhooks keep following the connected branch.

Suggestions

The sheet also lists suggestions computed on the control plane, each with a reason and one click to act. You can dismiss any of them; dismissals are remembered in your browser only.

Monorepos and build settings

A git source builds from two settings, and both are relative to the repository root:
  • Base directory is the Docker build context. Empty means the repository root.
  • Dockerfile path is the Dockerfile to build, for example apps/web/Dockerfile. It is relative to the repository root, not to the base directory, and it must sit inside the base directory when one is set. Empty uses Dockerfile in the build context.
Auto-detect (Railpack) only looks at the base directory, so a monorepo with no app at its root needs one of the two settings above. The Source tab states the resolved result in plain words (“Builds apps/glinr/deploy/Dockerfile with the repository root as the build context”), and the CLI prints the same sentence.

Finding the right settings

Detection lists Dockerfiles and build roots from the provider’s file-tree API without cloning the repository (GitHub, GitLab and Gitea; other hosts use a shallow in-memory clone). It ignores node_modules, vendor, dist and similar folders, and reads turbo.json, pnpm-workspace.yaml, nx.json, go.work, Cargo and npm workspaces and compose files to explain what it found.
In the dashboard, the Source tab shows the same list under “Detected in this repo”, with a one-click “Use this” on each suggestion. When an app uses auto-detect on a repository with no app at its root, the tab shows a “This looks like a monorepo. Pick a Dockerfile.” notice, and a failed build links to it. Apply the recommended suggestion straight from the CLI with levelrail-cli apps git-source detect <name> --apply. Invalid values (absolute paths, .., or a Dockerfile outside the base directory) are rejected with a 400.

The turbo prune pattern

A Dockerfile that runs turbo prune needs the whole repository as its context, because the prune step reads the root turbo.json and lockfile and then copies only the packages the app needs. Keep the base directory empty and point the Dockerfile path at the app’s file:
A self-contained app with its own Dockerfile and no workspace dependencies builds from its own folder instead:

How webhooks are processed

Push and pull request webhooks from all four providers follow one path. Every webhook creates a delivery history entry, whether it succeeds, fails, or does not match, so you can see and replay failed deliveries. All four providers post to POST /api/v1/webhooks/github/{name}. The path says github for backward compatibility with webhooks configured before the other providers existed. The provider is detected from headers (X-GitHub-Event, X-Gitlab-Event, X-Event-Key, X-Gitea-Event-Type), not the URL. The route takes no session or token, because providers cannot send one. The per-app webhook secret, generated when the git source was connected, is checked against the request instead. Each request is limited per client IP and app name before any lookup or write happens, so a client that never produces a valid signature is rejected cheaply. The budget is APP_WEBHOOK_RATE_LIMIT_RPM requests per minute (default 60), refilled continuously so a burst of back-to-back pushes does not trip it. A request over budget gets 429 with Retry-After. Set it to 0 to disable the limit. A push whose ref does not match the git source’s trigger mode is accepted (200) and ignored. Pull request events are routed separately: opened and synchronize deploy or redeploy a preview (when previews are enabled), and closed tears it down whether or not the pull request merged.

Webhook deliveries

Every inbound request is recorded, signature valid or not, matched or not. Bad signatures, unconnected sources, and deploy failures all show up. The app’s Source tab (/apps/{name}/source) has a “Recent webhook deliveries” panel.
Replay runs the same processing against the stored payload and headers once you have fixed the problem, so it can trigger a real build and deploy. It does not re-verify the original signature (you are already authenticated with the deploy ability) and does not write a second delivery row, since the replay is in the audit log.
Rows are kept for 30 days by default (APP_WEBHOOK_DELIVERY_RETENTION_DAYS) and swept every hour (APP_WEBHOOK_DELIVERY_SWEEP_INTERVAL). Deliveries that fail signature verification are capped separately: only the newest 20 per app are kept (APP_WEBHOOK_FAILED_DELIVERY_KEEP) and each stores at most 2048 body bytes (APP_WEBHOOK_FAILED_BODY_BYTES). This is debugging data, not a compliance trail.

Rotate the webhook secret

Rotating mints a fresh secret without touching the repository URL, branch, build config, services, or deploy token. The old secret stops verifying the moment the call returns, so update the repository’s webhook settings with the new value before the next delivery.
The git source card has a matching Rotate secret button that shows the new value once. The API route is POST /api/v1/apps/{name}/git-source/rotate-webhook-secret.

Preview environments

Previews are opt-in per app and off by default. Once enabled, opening a pull request against the repository’s target branch deploys an independent copy of the app named <app-name>-pr-<number>. New commits redeploy it in place, and closing or merging the pull request tears it down. A single app with a persisted multi-service services: map fans a preview out the same way POST /api/v1/apps/{name}/deploy-spec does for a real deploy.
apps previews list shows one row per preview with its pull request number, preview app, branch, status, domain, last update, expiry, whether it is stale, and any ephemeral or isolated databases. extend adds hours to one preview’s expiry (default 24).

Domains

When a control-plane primary domain is configured, the preview gets pr-<number>.<app-name>.<primary-domain>. A domain collision does not fail the preview: it deploys without a domain, and status_reason says why. For a multi-service preview only one fanned-out service, the one that carried a domain in production, claims the subdomain. The rest are internal only.

Pull request comments and commit statuses

A second toggle, independent of previews themselves, works on all four providers:
When on, each preview deploy posts a levelrail/preview commit status on the pull request’s head commit: pending while building, success once live (with the preview URL), failure if the deploy failed. Messages are truncated to 140 characters on GitHub and 255 on GitLab. Each pull request also carries a single comment, found by a hidden marker and edited in place, showing the state (building, ready with the URL, failed with a short reason, removed with the reason), the commit, and the last update time. If someone deletes it, the next update posts a fresh one. This uses the same connected provider credentials as repository and branch calls. If the repository is not hosted on the connected instance, or no provider is connected, it is a silent no-op that never fails the preview.

Preview environment variables

A preview inherits its parent app’s env vars wholesale (Env, SecretEnv, and VaultEnv copy unchanged). Every preview container also gets PREVIEW_URL (when a domain was assigned), PREVIEW_PR_NUMBER, and PREVIEW_BRANCH. Pipeline runs triggered by a pull request get the same three variables for the live preview built from their branch. To give previews a different value for one key, declare an override on the parent app:
In the dashboard this is the Preview env overrides card on the app’s Environment tab. Overrides apply the next time a preview is created or updated, never touch the parent app’s running deploy, and survive parent redeploys. They apply to single-service previews only. Branch-scoped overrides apply only when the preview’s head branch matches a pattern, either an exact name or a glob such as release/* (* matches any run of characters except /, so release/* matches release/1.0 but not release/1.0/hotfix). Resolution order is branch-scoped override, then the unscoped preview override, then the app’s own env.
--secret encrypts the value like apps secrets set does, and it is never returned once saved. Setting the same pattern and key again replaces the override. When two overrides for a key both match, the exact match wins. clear takes the override’s id from list or set, because a branch pattern can contain /. In the dashboard this is the Branch env overrides card on the Environment tab. Like the unscoped form, it applies to single-service previews only.

Databases in previews

A preview that declares ephemeralInPreviews databases in app.yaml gets its own disposable instance, removed with the preview. A database that declares isolatedInPreviews instead gets its own credential on the existing database (a Postgres role or a Redis ACL user) with no new container. See the app.yaml reference.

Limits, forks, and approval

The control plane caps live previews per app (APP_PREVIEW_ENV_MAX_PER_APP, default 10) and across the platform (APP_PREVIEW_ENV_MAX_TOTAL, default 50). 0 turns a cap off. When a new pull request would exceed a cap, the app’s policy decides:
  • evict_oldest (default): the oldest live preview is removed to make room, and its pull request comment says why.
  • reject: the new pull request is not deployed. Its row shows limit_reached with the reason, and the next push retries once a slot frees up.
A push to a preview that is already live never counts as a new preview, and pull requests waiting for approval hold no slot. A pull request whose head repository differs from the target repository gets no preview unless you opt in per app (allow_fork_previews, default off). A payload that names no repository is treated as a fork. The preview shows awaiting_approval and the pull request comment explains how to approve. Approving deploys that commit once, and a later push from the fork waits for approval again. A fork preview also never receives the app’s environment variables or secrets unless the separate allow_fork_secrets policy is on (--fork-secrets on apps previews settings).
Approving is a security decision: the fork’s code runs on your server, and with fork secrets on it runs with the app’s environment variables and secrets. The dashboard asks you to confirm, and the CLI requires --yes.
Set the per-app policy on the Source tab under Preview limits and safety, or from the CLI:
--ttl-hours overrides the platform TTL for one app, and 0 restores the default. apps previews settings <name> sets the same policy plus --fork-secrets, --max-previews, --memory, --cpu, --idle-sleep-minutes, --database, --seed-database and --gate.

Cleanup

A preview is removed when its pull request closes or merges, when it expires, when it is evicted, or manually.
teardown uses the same path as the automatic pull-request-closed webhook, so use it for stuck builds or after a partial teardown. The dashboard has a Tear down button on each preview row.
At startup the control plane repairs what a crash can leave behind: previews stuck in deploying longer than APP_PREVIEW_STUCK_AFTER (default 30 minutes) are marked failed, and preview services that no preview owns are removed with their containers.The TTL sweep covers a pull-request-closed delivery that never arrived. A background loop tears down any preview whose last update is older than APP_PREVIEW_TTL (default 7 days, or the app’s own --ttl-hours). sweep, or the Sweep stale previews button that appears once a preview is stale, runs it immediately. GET /api/v1/apps/{name}/previews returns a stale field showing what the next sweep would remove.

API reference

The root tier covers routes that read or write credential material itself, the same tier as PUT /api/v1/settings/ingress. The read:sensitive and write:sensitive tier covers repository, branch, and project listing and use-as-source: they disclose private repository names but never the connection’s credentials.

Known limits

Bitbucket’s repo:push payload marks each change as a branch or a tag, but the webhook parser discards that and always treats the change as a branch. A Bitbucket tag push is therefore never recognized, so release mode never deploys from Bitbucket. This fails closed: a tag push simply does not deploy.
Both have their own release webhook events that Levelrail does not subscribe to. A tag push on either still triggers release mode correctly.
use-as-source connects the repository and registers the webhook, but does not carry the OAuth token forward as the git source’s deploy token. A private repository only builds once you paste a personal access token into the app’s git source card. GitHub does not have this gap, because its installation token is minted fresh on every call. Likewise can_auth_clone in GET /api/v1/git-providers is true only for GitHub.
A GitHub Enterprise Server connection works for the App itself, but the authenticated-clone path only recognizes github.com URLs.
Disconnecting deletes the local connection and its secrets. It never revokes the token, uninstalls the App, or deletes the OAuth application on the provider’s side.

Next steps

Deploying and managing apps

The app lifecycle: deploy, rollback, resources, health checks.

Domains and ingress

Domains for production apps and preview environments.

Pipelines

Run tests and gated deploys on push, tag, or pull request.

app.yaml reference

The deployment spec behind multi-service deploys.
Last modified on October 12, 2026