Skip to main content

Your Git-Push PaaS Assumes GitHub. Here's What Wiring It to Forgejo + Woodpecker Actually Takes

8 min readDora NodaDora Noda
Share
On this page

Push a repo, get a running service — that's the pitch every git-push PaaS makes, bex.co included. What none of them advertise is the assumption baked into "push": a webhook from GitHub, carrying a GitHub-shaped payload, into a receiver that was written and tested against GitHub's webhook format first. A team that's already self-hosted its git forge onto Forgejo and wired Woodpecker CI in front of it doesn't get "push a repo, get a running service." It gets two systems that don't know about each other, hand-registered against the same repo, with no shared answer to "did this push actually pass CI before it deployed."

That's the concrete gap this post is about: not whether to self-host your git forge (a governance question already settled in Forgejo's favor for most 2026 teams), and not whether GitHub Actions' control plane is a lock-in risk (a pricing episode that already made that case). It's the narrower, more mechanical question a team hits the moment they've made both of those calls: what does a git-push PaaS's build system actually need to change to treat a Forgejo-hosted repo with Woodpecker already wired to it as a first-class target, instead of a DIY workaround the platform happens not to break?


Two Ways to Wire It — and Only One Has a Source of Truth

The naive setup is two independent webhooks on the same repo. Woodpecker registers its own webhook against Forgejo the moment a repo is enabled in its UI — that part is automatic and well-documented. A git-push PaaS's activator, if it's built the way bex's is, wants a webhook of its own: something fires on push, and that fires a build-and-deploy. Register both, and a single git push now triggers two independent listeners racing each other. Woodpecker runs its test suite. The PaaS builds and deploys the container. Neither waits on the other, because neither knows the other exists.

That's not a hypothetical failure mode — it's the default outcome of following each project's own "getting started" docs independently. And it means a red Woodpecker build and a live deploy can both be true at the same moment, which defeats the entire reason a team wired CI in front of their deploys.

There are exactly two ways to fix that, and they trade off differently:

ModelHow it worksWhat it buysWhat it costs
A — Independent triggersPaaS webhook and Woodpecker webhook both fire on push, deploy proceeds regardless of CI resultSimplest to wire; deploy latency unaffected by test runtimeNo gate — a failing test and a live deploy can coexist; CI becomes advisory only
B — CI-gated deployWoodpecker's pipeline calls the PaaS's deploy API (or triggers a deployment event) as its last step only on success; the PaaS's own push webhook is disabled or ignored for that repoDeploy only happens after tests pass — the property the team wired CI in forRequires the PaaS to expose a deploy trigger Woodpecker can call, not just a push listener; one more moving part to get the auth right on

Model B is the one worth shipping as a first-class feature, because it's the only one that gives CI a job. Woodpecker already supports this shape — a pipeline can end on a deploy event, triggered either from within Woodpecker itself or via an external call, specifically to chain "tests passed" into "now deploy." The missing piece isn't in Woodpecker; it's that today, a git-push PaaS built GitHub-first doesn't expose a deploy trigger that isn't itself a raw push webhook. Wiring Forgejo + Woodpecker cleanly means adding one: an authenticated API endpoint (or CLI call) that Woodpecker's final pipeline step can invoke, decoupled from the platform's own webhook receiver entirely.


The Webhook Payload Isn't a Drop-In Swap

Even the naive Model A setup assumes the PaaS's webhook receiver can understand what Forgejo sends, and that's not automatic. Forgejo's push event arrives with an X-Forgejo-Event: push header and a JSON body carrying ref, before, after, compare_url, and a commits array — structurally close to GitHub's push payload, but not identical field-for-field, and a receiver hard-coded to GitHub's exact shape (checking for X-GitHub-Event, reading repository.full_name the GitHub way) will silently drop or misparse it rather than fail loudly.

Forgejo does ship compatibility headers alongside its native ones specifically so tools built for GitHub, Gogs, or Gitea don't all need bespoke integrations — which means the honest engineering answer isn't "write a second parser from scratch," it's "branch the existing GitHub-shaped receiver on the compatibility header and confirm the field names it reads are still present." That's a half-day of work for a team that already has a GitHub webhook receiver, not a rewrite — but it's real work, and it's exactly the kind of "should just work" assumption that breaks quietly in production the first time a Forgejo user pushes and nothing happens.


Reporting Status Back Is the Half Nobody Builds First

A GitHub-integrated PaaS almost always posts a commit status or check run back onto the pushed SHA — the green check next to a commit that says "Vercel: deployed," "Render: build succeeded." That write-back is what makes a deploy visible inside the forge, on the PR, without a developer tabbing over to a separate dashboard. Forgejo (inherited from Gitea) exposes the same shape of API for it — a REST endpoint under /api/v1/repos/{owner}/{repo}/statuses/{sha} that accepts a state, a target URL, and a description, the same commit-status primitive GitHub's API popularized.

The reason this half gets skipped first isn't technical difficulty — it's that a webhook receiver is required for the platform to function at all (no webhook, no trigger, no deploy), while a status write-back is optional in the sense that the deploy still happens without it. That makes it exactly the kind of feature that ships last, or not at all, on a GitHub-only integration and then simply doesn't exist when someone adds Forgejo support by copying the receiver and stopping there. Skipping it isn't a missing nicety — it's the difference between "the deploy happened somewhere" and a developer being able to see, on the commit itself, that it did. For a Forgejo-hosted repo where Woodpecker already posts its own commit status for CI, a PaaS that doesn't post one of its own for the deploy leaves a visible gap on the same commit: a Woodpecker check, and silence.


The Auth Surface: One OAuth App, Scoped Down

Both webhook registration and status write-back need something to authenticate as. Woodpecker's own Forgejo integration requires an OAuth2 application registered in Forgejo before it can create webhooks or read repos on a user's behalf — that's not a Woodpecker-specific quirk, it's how any third-party integration talks to a Forgejo instance. A PaaS wiring itself to Forgejo the same way needs its own OAuth app, registered once per Forgejo instance (self-hosted, so there's no single central app id to ship — this has to be a setup step the platform's admin UI walks a team through), scoped to exactly two things: write:repository for webhook management, and write:issue-adjacent status-write access for posting back to a commit. Broader scopes than that buy nothing and widen the blast radius if the credential leaks.

That registration step is the part that turns "we tested this against our own Forgejo instance" into "any team can point their Forgejo instance at us" — a one-time admin flow, not a hardcoded integration, because there is no single Forgejo to integrate against the way there's a single github.com.


What Ships First

Lined up, the concrete build-out for first-class Forgejo + Woodpecker support looks like four pieces, in the order they unblock each other:

  1. A Forgejo-aware webhook receiver — branch the existing push-event parser on Forgejo's compatibility headers, confirm field mappings, ship it behind the same activator that already handles GitHub.
  2. A deploy-trigger API distinct from the push webhook — the endpoint Woodpecker's final pipeline step calls, so Model B (CI-gated deploy) is possible instead of Model A (race) being the only option.
  3. Commit-status write-back — post deploy state to the same /statuses/{sha} endpoint Woodpecker already posts CI results to, so a developer sees both checks on one commit.
  4. A per-instance OAuth app registration flow — the admin-facing setup step that makes the above three work against a Forgejo instance, not the Forgejo instance, since self-hosted means there isn't one.

None of these four is individually hard. What makes the difference between "we support Forgejo" and "we tested against Forgejo once" is shipping all four together — a webhook receiver alone gets you Model A's race condition; stopping there is the gap a team hand-wiring two systems on their own inevitably hits first.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with a Render-compatible API and a build pipeline designed to sit downstream of whatever CI your git forge already runs, not race against it. Star the repo on GitHub or deploy your first app today.

Sources

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex