Close-up of a computer screen showing command-line code, illustrating running git clone and git diff against the United States Code repository from a shell

Moving Off GitHub and GitLab to Forgejo

October 31, 2026 · 13 min read · By abel

I have moved repositories off GitHub and GitLab to self-hosted Forgejo for multiple organizations of various sizes, including my own systems. Across those migrations, the reasoning was consistent: in a period when LLMs (large language models) and AI-assisted development are changing how fast code is written, reviewed, and shipped, the forge should be infrastructure the organization controls, not a rented service whose roadmap is set somewhere else. To be clear, this is not a claim that everyone should do the same. If you depend on GitHub’s network, Copilot, the Actions marketplace, or a large team’s workflow, staying might be rational.

Here is what the migrations involved, why they were started, what broke, and what I would do again. For transparency, I have no affiliation with Forgejo or Codeberg; each organization ran the forge on infrastructure it controls.

Rear of a server rack at the NERSC data center, with network and power cabling
Self-hosting moves the control plane into a rack you own, and the pager into your pocket. Photo: Derrick Coetzee, Wikimedia Commons, CC0.

The short version

Forgejo is a hard fork of Gitea, written in Go. Since v9 it is licensed under GPLv3+. It runs as a single binary with SQLite or PostgreSQL, and its governance is collectively defined by contributors, with Codeberg e.V., a nonprofit, looking after the domains. That combination is more important than any single feature: a small, self-contained forge with a non-profit custodian and a real license.

The inventories included repositories, issues, pull requests, Actions runs, secrets, and SSH keys. We rotated the credentials that mattered and deleted the ones we did not need. Copying the repositories was straightforward. The effort went into everything around them: CI workflows, issue and pull-request metadata, OAuth callbacks, webhooks, runner labels, submodules, LFS objects, and the small integrations that expect GitHub’s request formats. Those pieces sit outside the code, and we regularly hit rate limits from providers such as GitHub while moving them.

There are still bills to pay for the Forgejo setup, but they work differently. In many cases we reused infrastructure the organization already had, so the cost was not metered per minute like GitHub Actions runners. Companies already on AWS could put the runners on EKS with autoscaling and spot instances, which is cheaper when the cluster is sized and tuned well. The organization takes on upgrades, backups, TLS, runner isolation, and the 2 a.m. page when storage fills. It also decides whether its code is used for model training, rather than leaving that call to a vendor.

Why leave

Three things pushed the migrations, in this order: reliability, pricing, and the direction of the platform.

Reliability. GitHub’s own availability reports are the best argument. It reported nine incidents in May and eight in July. The July report also addresses the August 6 Actions incident and calls it “unacceptable in both its impact and particularity of its duration.” That is GitHub’s language, not mine. A forge can have a bad day; a CI system that is central to publishing and deployment can have a very expensive one.

Pricing. GitHub announced a $0.002 per minute charge for self-hosted Actions runners, then postponed it after feedback. Postponed is not cancelled. No one wants to build a workflow around a price that can reappear. For a small setup, the charge would have been small; the principle is larger. The organization already pays for the machine. Charging the control plane per minute makes the self-hosted option feel less self-hosted.

The platform’s direction. GitHub is now an AI company as much as a code-host company. That is a strategy, not a criticism. But the strategy shows up in the product: Copilot, agents, and the data policies around them. GitHub’s own announcement says that from April 24, interaction data from Copilot Free, Pro, and Pro+ users, including inputs, outputs, code snippets, cursor context, and repository structure, is used to train and improve its models unless the user opts out. Business and Enterprise users are excluded. If you use Copilot, read the policy and decide for yourself. The migrations reflected a decision not to have the forge and model-training data on the same account.

GitLab was the obvious alternative. It is a good product, but it was not the right fit for this job. Its protected environments and deployment approvals are Premium and Ultimate features; the free tier does not include the deployment controls the migrations needed. Its own single-node baseline is 8 vCPU and 16 GB of RAM. That is reasonable for a full DevSecOps (development, security, and operations) platform serving a team. It is a lot of surface for smaller deployments. A smaller tool fit the job. Forgejo can grow with the organization instead. Its docs recommend SQLite for low-to-moderate activity, which keeps the initial footprint small, and MySQL or PostgreSQL for heavier use. Forgejo does not publish a fixed CPU/RAM minimum, but its installation guide treats SQLite as enough for at least 10 users; in our smallest setup, that install ran on a 2 vCPU / 4 GB VM before moving to larger VMs or EKS with more runners.

Why Forgejo

Forgejo fits the shape of the problem. It is a hard fork of Gitea, not a skin. The FAQ is unusually clear about the things that usually stay vague: governance is contributor-defined, Codeberg e.V. is a non-profit registered in Berlin and holds the domains, and versions v9.0 and later are GPLv3+. That is a real license with a real custodian, not a promise in a blog post.

Operationally, it is one Go binary and a database. The deployment shape followed each organization’s infrastructure: a small VM (2 vCPU, 4 GB of RAM) for the smallest setup, managed Kubernetes on EKS where that was already the platform, and conventional VMs elsewhere. The database is the part to watch. The spread describes the deployments, not a benchmark against GitLab. A small forge does not need Rails, Sidekiq, Redis, and a fleet of background workers. It needs Git, a web UI, authentication, and a runner.

Actions was the deciding feature. Forgejo Actions reads workflows from .forgejo/workflows and uses a separate runner. The admin guide is direct about the security model: the runner performs remote code execution, so it needs isolation. The workflow reference is equally direct: it only partly documents the syntax and warns that the two systems are not the same. Across the migrations, roughly 95 percent of the workflows ran after path, action, and label changes. The last 5 percent were the GitHub-specific ones, and they were not all worth porting.

Close-up of programming code on a dark screen
The migration is mostly code, but the work is in the edges: action resolution, runner labels, tokens, and permissions. Photo: Martin Vorel, Wikimedia Commons, CC BY-SA 4.0.

What went well

The migrations did what they were meant to do. We moved the repositories, CI, and access for each organization, and daily work got faster. The web interface responds quickly, CI has less platform overhead, there are fewer restrictions, and the bills are smaller. Uptime now depends on how well each organization runs its own setup: backups, monitoring, and upgrades, rather than on a vendor’s status page. That is the trade, and for these organizations it was worth it.

Most of that came from following the same process every time, with a small internal tool handling the repetitive parts. We started with a catalog: every repository, how large it was, how many open issues and pull requests it had, what automation it used, and which credentials it depended on. Then we mapped the existing organization and teams into Forgejo. Next, we created private copies of each repository inside Forgejo and left automation switched off. GitHub stayed live, and the copies updated on their own while the new server caught up.

Before switching anyone over, we compared the two sides branch by branch and tag by tag. That caught copies that looked complete but were missing history. The switch itself happened one repository at a time: we reviewed its automation, adjusted the size of the machines that run its jobs, fixed steps that behaved differently, then froze GitHub, did a final sync, made the Forgejo copy the live repository, turned automation back on, and set up a reverse copy to GitHub as a fallback. If anything went wrong, we could roll back that one repository without affecting the others. Developers got a small script to update their local repository addresses. Every step was tested in a dry run before anything changed.

What broke

Git itself moved cleanly. git clone --mirror and a push get the objects across. The failures started when the repository was not just a repository.

  • Workflow paths and action resolution. Workflows move from .github/workflows to .forgejo/workflows, which is mechanical. The hard part is uses:. A GitHub workflow can point at an action by name, such as actions/checkout@v4, and expect GitHub to resolve it. Forgejo resolves short action names against DEFAULT_ACTIONS_URL (by default https://data.forgejo.org), not GitHub’s marketplace. Some common actions have compatible copies there; others do not exist. We pinned the actions we needed to a specific version or commit, imported a few into our own instance, and replaced the rest with shell commands. A workflow that parses is not proof that the action exists or behaves the same way.

  • Runner labels. runs-on: ubuntu-latest is not a thing on a fresh Forgejo runner. We registered labels that matched our actual workers, then changed the workflows. This is also where the security review starts. A runner label is a scheduling decision and a trust boundary: it determines which machine takes the job and what that machine can access. Keep untrusted pull requests away from runners that hold secrets.

  • Tokens and permissions. GitHub’s GITHUB_TOKEN and permissions: block do not map exactly onto every Forgejo release. Forgejo exposes FORGEJO_TOKEN and a compatibility token, but the scopes and defaults are not identical, so a workflow that looks fine can end up with more access than it needs. We audited every workflow that writes to a repository, a package registry, or a deployment target and replaced broad tokens with narrow ones. This turned out to be the most valuable part of the migrations: it removed credentials that no workflow needed, limited what a compromised job could reach, and forced us to write down what each workflow actually writes. The repository import was mechanical; this was the part that reduced risk.

  • Submodules and LFS. A mirror push moves the Git objects, but two things can stay behind. Relative submodule URLs depend on the repository path, so a mirror that lands under a different path can point at the wrong place, and credentials written into .gitmodules can leak. LFS is separate: the mirror carries pointer files, not the LFS blobs, so the new repository can look complete until a checkout or CI job asks for the binary. We rewrote the submodule URLs, removed credentials, fetched all LFS objects from the old remote and pushed them to Forgejo, and verified a clean clone and LFS fetch before deleting the old copy. If you skip this, a fresh clone can look fine while a CI job fails much later.

  • Issues and pull requests. Forgejo’s migration imports the obvious fields, but not every piece of GitHub metadata has a home. Labels, milestones, review comments, cross-references, reactions, and project boards need checking. We accepted some loss and exported the rest as JSON. Decide what “good enough” means before the import, write it down, and be explicit about the fields you are willing to lose.

  • OAuth and webhooks. Every callback URL and every payload consumer is a small integration. GitHub OAuth apps become Forgejo OAuth2 applications; the callback path changes. GitHub webhooks become Forgejo webhooks, and the payload shape is close but not identical. We run a small receiver that logs and validates both during the parallel period.

  • The things you do not get. Code search is less capable. The mobile and marketplace ecosystem is smaller. Dependabot and CodeQL do not exist in the same form; Renovate and Semgrep/Trivy can cover a lot, but you are assembling the stack. Copilot is not there. If those are load-bearing for you, Forgejo is the wrong trade.

The other failure mode is organizational: the migration becomes a side project that never finishes. We avoided that by moving new repositories first, then mirroring old ones, then cutting over CI in batches. The old host stayed read-only until the new one had survived a full week of normal work.

What I would do again

Move the new work first. Create new repositories on Forgejo and leave the old ones where they are. This gives you a real workflow without a flag day. Migrate the archive later, when the new host has earned trust.

Mirror old repositories before cutting over. Run Forgejo as a pull mirror for a week before you ask anyone to change their remote. You will find the LFS and submodule problems while the old host is still the source of truth. For small repositories, a mirror is also a cheap backup.

Test a representative handful of workflows first, five for example. Pick the workflows that represent your risk: one build, one test, one container publish, one deploy, one scheduled job. Fix the labels, tokens, action resolution, and permissions on those five. The rest will follow the same pattern or fail for the same reason.

Inventory secrets before you move them. The migrations involved dozens of secrets and hundreds of SSH keys. That is too many to copy blindly. We rotated the ones that mattered, deleted the ones that did not, and moved the rest into the Forgejo secret store. A migration is a good time to shrink the blast radius.

Write the backup and restore test first. A self-hosted forge is only as good as its restore. Back up the database, the repositories, the LFS objects, and the runner configuration. Then restore into a clean environment and clone a repository from the restored instance. If that test is not boring, you are not done.

Budget for the maintenance. The hosting bill is small compared with the time. Expect a week for the migration and another week for CI, not a weekend. Put the upgrade cadence and the security-advisory subscription in the same calendar as the backups.

Migration checklist

  1. Inventory repositories, default branches, tags, submodules, LFS objects, packages, secrets, runners, webhooks, OAuth apps, and deploy keys.

  2. Stand up Forgejo with PostgreSQL, TLS, backups, and monitored storage. Set DEFAULT_ACTIONS_URL deliberately.

  3. Create users and organizations, map teams, and configure authentication (OAuth or LDAP) before importing repositories.

  4. Mirror repositories and verify a fresh clone, a tag checkout, a submodule update, and an LFS fetch.

  5. Import issues and pull requests; check labels, milestones, review comments, cross-references, and attachments. Export what cannot be imported.

  6. Rewrite workflows under .forgejo/workflows; pin or mirror actions; replace GitHub-only steps; set runner labels and token permissions.

  7. Move secrets, rotate credentials, register runners, and isolate untrusted jobs from secret-bearing runners.

  8. Update OAuth callbacks, webhooks, deploy keys, package registries, and links in documentation.

  9. Recreate branch protection, required checks, and deployment approvals. Test a rejected merge and a blocked deploy.

  10. Run the old and new hosts in parallel for a week; compare CI results and fix the long tail.

  11. Cut over DNS and remotes, keep the old host read-only, and archive it after the first clean backup and restore.

Bottom line

Forgejo is for organizations that want control of the forge and are prepared to own the operations. It is GPLv3+, contributor-governed, and free to install; the real cost is infrastructure and maintenance. It can run on a single VM or on Kubernetes, including EKS, and add PostgreSQL and more runners as usage increases. That said, it is not the right default for every team. Teams that depend on Copilot, a broad action marketplace, advanced security products, or a vendor support contract will usually be better served by GitHub or GitLab. Self-hosting is a trade: control for maintenance.

The trade was control. The forge becomes part of the organization’s infrastructure instead of a service it rents. That is the whole decision, and the rest is work.

Sources