On 17 August 2026, GitHub was in the middle of a critical incident. For us, the visible symptom was simple: repository-backed automation stopped moving.
At first this looked like an ordinary engineering inconvenience. We could not merge pull requests reliably. Builds were delayed. Deployments were blocked. GitHub had experienced several disruptions in the preceding days, so this felt like another bad morning in a rough week.
Then we traced what actually depended on GitHub.
By the end of the day, we had moved nine repositories, 333 pull requests, our main build pipeline and our production deployment workflow to a Forgejo instance running on our own infrastructure. We did not move everything, and that distinction matters. This is not a victory lap about deleting a SaaS account. It is a postmortem about discovering that a developer tool had quietly become part of our production architecture.
The Outage Exposed the Real Dependency
DanubeData provisions infrastructure through GitOps. The application writes desired state to a repository, controllers reconcile that state into Kubernetes, and customer resources appear. More than a thousand deployed applications ultimately depend on that path continuing to work.
That means Git availability is not only about whether an engineer can merge a branch. It affects whether automation can read and write the desired state behind customer infrastructure.
We had mentally classified GitHub as part of the development environment. Architecturally, it had become part of the control plane.
That was the incident. The GitHub outage was merely the event that made it visible.
The useful question is therefore not, “How irritating would a GitHub outage be for our developers?” It is:
Which customer-facing operations stop when our Git provider is unreachable?
Every engineering team should be able to answer that question, regardless of whether it uses GitHub, GitLab, Forgejo or something else.
Why We Could Move Quickly
Our CI compute was already self-hosted. GitHub scheduled the work, but our own runners performed the builds and tests. The expensive part — a large test suite tuned to run across 40 parallel workers — already lived in versioned shell scripts.
That separation saved us.
When CI logic lives entirely inside a provider-specific YAML file, changing providers becomes a rewrite. When the workflow mainly validates inputs and calls repository-owned scripts, changing providers is mostly orchestration work. We still had compatibility problems, but we did not have to reimplement the meaning of “build”, “test” or “deploy”.
This is a design choice we now value more than we did before the migration: keep workflow files thin and keep operational logic portable.
Why Forgejo
We considered GitLab Community Edition. It is capable, mature and familiar, but its operational footprint did not match our needs. We had nine repositories containing roughly 400 MB of Git history and a small engineering team. We did not need another large platform to operate.
Forgejo gave us the parts we needed: Git hosting, pull requests, issues, a container and package registry, repository mirrors, and an Actions-style CI system. It was small enough to understand and operate without turning the migration into a separate infrastructure programme.
Its governance also aligned with the reason we were making the move. Forgejo is a fork of Gitea whose domains are held by Codeberg e.V., a German non-profit dedicated to free software. Self-hosting does not remove every dependency, but it does give us control over upgrades, storage, backups and recovery.
Most importantly, Forgejo was not a theoretical choice. We could deploy it, migrate a repository and run a real pipeline that day.
What We Moved in One Day
The first cutover covered the engineering path we could verify safely:
- nine source repositories and their Git history;
- 333 pull requests and associated issue metadata;
- pull-request validation and main-branch builds;
- container image publication;
- the DanubeData production deployment and rollback workflows;
- the reusable scripts behind build, migration, rollout and health checks; and
- push mirrors back to GitHub.
We treated the mirror as part of the architecture, not as a temporary migration aid. Moving from one forge to another and then keeping only one copy would simply relocate the single point of failure. Every push to Forgejo is mirrored back to GitHub, giving us an independent copy of the repositories and an obvious recovery route.
The migration was fast. Making it trustworthy took longer. The following failures were where most of the engineering work happened.
What Broke
Large imports can fail at the reverse proxy
Forgejo performed our repository migration synchronously inside the HTTP request. One repository took longer than the reverse proxy's ten-minute read timeout. The proxy returned a 504, the client disconnected, and the import stopped after 196 of 333 pull requests.
The incomplete import also left repository metadata in a state the UI did not explain well: the repository remained marked as migrating, and its issue counter had not been created. New issues and pull requests then failed with a database uniqueness error that pointed nowhere near the actual cause.
Our lesson was straightforward: test the largest repository first, increase proxy timeouts before migration, and verify counts and repository state after every import. A green repository page does not prove the migration completed.
Workflow directories are a fallback, not a merge
Forgejo uses workflows from .forgejo/workflows. If that directory is absent, it can fall back to .github/workflows. It does not combine both directories.
We added one Forgejo-specific workflow and unintentionally stopped every GitHub workflow in the repository from being discovered. Pull requests opened successfully, but no checks ran.
The fix was to port each repository's workflow set as a unit. The broader lesson is to test workflow discovery itself — not only workflow syntax — before declaring a CI migration complete. Forgejo's own documentation makes the fallback explicit in its Actions overview.
Familiar syntax does not mean identical behaviour
Forgejo Actions deliberately looks familiar to GitHub Actions, but it is not a drop-in implementation of every GitHub service.
Artifacts work within Forgejo, but cross-workflow artifact retrieval is not currently available in the same way we had designed our GitHub build-to-deploy handoff. The official documentation also requires Forgejo-compatible artifact actions rather than assuming every upstream version will work unchanged.
Instead of rebuilding a brittle artifact bridge, we published immutable build provenance through Forgejo's package registry. The deploy workflow resolves a successful build, downloads its provenance, verifies every image digest, and deploys those exact digests. Removing the provider-specific artifact hop made the design simpler and stronger.
The runner is part of the product
We run some jobs directly on the host because our builds drive Docker Compose with bind mounts. Host mode is useful, but it is not an abstraction: jobs receive the host's toolchain and security boundary.
Our first run failed because Node.js was missing. Even a workflow that is mostly shell can depend on Node through reusable actions such as checkout. The next failure was a missing Python module. Neither problem was in the application; both were properties of the runner image.
We now treat runner dependencies as infrastructure code. The runner version, labels, tools, permissions, workspace cleanup and disk usage are all part of the CI contract.
Self-hosted actions must actually be self-hosted
A self-hosted forge can still depend on an external forge every time a workflow evaluates uses: actions/checkout. Forgejo resolves shorthand action references through its configured DEFAULT_ACTIONS_URL.
We set that value to self and mirrored the actions we use into our own instance. Otherwise a GitHub outage could still prevent Forgejo from starting a build because checkout itself had to be downloaded from GitHub.
Forgejo recommends fully qualified action URLs to make the source unambiguous. We went one step further because removing that runtime dependency was one of the migration's explicit goals.
Build numbers are local state, not provenance
This was the most migration-specific failure.
Our image tags use YYYYMMDD-BUILDNUMBER. The GitHub pipeline had reached build 3252. The new Forgejo pipeline began at build 1.
A deployment that sorted tags to find “the newest build” therefore ranked 20260817-3252 above 20260817-42. It consistently selected an older GitHub build even after newer Forgejo builds existed.
The bug was not lexical sorting. The bug was treating a CI provider's private counter as a globally meaningful ordering.
We removed that assumption. Builds now publish explicit provenance containing the source commit, run identity, immutable tag and image digests. Deployment resolves the latest successful deployable provenance record and then verifies every digest before touching production.
That design survives a runner replacement, a forge migration and a counter reset because it relies on evidence, not naming conventions.
What We Deliberately Left on GitHub
A one-day migration is only responsible if “done” has a narrow definition.
The GitOps repository stayed on GitHub during the first cutover. It is the repository whose availability affects customer provisioning, but that is exactly why it moved last rather than first. Pointing live reconciliation at a forge with only hours of production history would have exchanged a known external risk for an unmeasured internal one.
Some public release workflows also stayed. Our Terraform provider is distributed through a registry that consumes GitHub releases. Other repositories publish to public ecosystems with established credentials and expectations. Moving those workflows is a distribution change, not merely a CI change, so each one needs its own cutover and rollback plan.
This staged approach may sound less dramatic than “we left GitHub in a day”. It is also more accurate. We moved the workflows we could prove, mirrored everything, and refused to turn an outage response into an uncontrolled production migration.
Owning the Forge Means Owning Its Failures
Self-hosting does not manufacture reliability. It changes who is responsible for it.
Our Forgejo instance needs monitoring, upgrades, tested backups and off-host recovery. Its database must be backed up. Its repositories and package registry must fit within known storage limits. Its runners execute repository code, so their credentials and network access must be designed as security boundaries. A full disk or an over-privileged runner can cause an outage every bit as real as a SaaS incident.
We are not claiming that one machine we operate is inherently more reliable than GitHub. We are saying that the architecture is now explicit:
- source repositories have an independent mirror;
- CI logic lives in portable scripts;
- builds publish immutable provenance;
- deployments verify exact digests;
- the forge and runners are monitored infrastructure; and
- high-risk production dependencies move only after the new path has earned trust.
Those properties matter more than the logo above the pull-request page.
What This Changed at DanubeData
The migration reinforced a principle we already apply to customer infrastructure: ownership is useful only when it comes with an exit path.
We build DanubeData on standard components — Kubernetes, KubeVirt, PostgreSQL, Redis-compatible databases, RabbitMQ, S3-compatible storage and ordinary container images — because portability is operational leverage. The same principle now applies to our own development platform. Our code can move. Our CI logic can move. Our build artifacts are content-addressed. Our repositories exist in more than one place.
The goal is not to avoid every external service. That would be neither practical nor desirable. The goal is to know which services sit in a customer-facing path, reduce accidental coupling, and retain a tested way out.
GitHub's incident did not teach us that GitHub is bad. It taught us that we had assigned GitHub a production role without naming it, monitoring it as such, or designing a fallback. That was our architecture mistake to fix.
The Question to Take Back to Your Team
You do not need to migrate to Forgejo tomorrow. You should, however, trace what happens when your Git provider disappears for an hour.
- Can you still deploy a known-good build?
- Can your platform provision customer resources?
- Do your actions, dependencies and release assets come from the same provider?
- Is your CI logic portable, or trapped inside provider YAML?
- Do you have a recent mirror that you have actually tested?
If any answer surprises you, that is the work. The choice of forge comes afterwards.
Running Your Own Forge
If you want to evaluate the same path, our guide to self-hosting Gitea or Forgejo covers a production setup from scratch, including runners, TLS and backups.
A small team can run Forgejo comfortably on a modest VPS. CI is usually the larger workload, so size runner capacity for your builds rather than for the forge itself. Keep backups off the forge host, monitor disk growth, and retain a mirror somewhere operationally independent.
That is the unglamorous part of sovereignty: not owning the server, but owning the recovery plan.