Skip to main content

GitHub to Forgejo: what to get right in the two weeks before a one-afternoon switch

Florian Schauer, Senior Site Reliability Engineer

5 min read

On 6 August 2026, GitHub’s Actions incident ran from 15:22 UTC until 02:04 the next morning. At the peak, 71% of runs failed. On 17 August, GitHub had another outage from 13:28 to 21:15 UTC, its second significant incident that month. We deploy to production through CI, so both outages meant we could not ship.

Those two days started it, but reliability was not the only reason. Our production already runs on our own Kubernetes clusters in the EU, as a principle and for GDPR; the two repositories behind our product, their CI logs and our images were the exception. And a self-hosted forge plus our own CI machines costs less than half of what GitHub did.

On 7 October, Forgejo became the authoritative home of both. The cutover took under two hours, the migration two days, after roughly two weeks of preparation. Here is what we’d advise anyone doing the same.

Inventory everything that talks to the forge

Our monorepo and our GitOps repository moved. Public repositories stay on GitHub, because the forge is only reachable over our VPN. The repositories were the easy part. Hanging off them were mobile and desktop CI, the container registry every cluster pulls from, the git source of every ArgoCD Application on four clusters, the remote our provisioning service commits to, and issue numbers people link to.

Linux CI runs on dedicated VMs outside our clusters, so a heavy build can’t starve the cluster hosting the forge. One workflow stayed on GitHub on purpose: a daily check of our break-glass access path. A check that runs on the same infrastructure as the forge goes quiet exactly when that infrastructure is down.

Takeaway: list every system that reads from or writes to the forge; each is a repoint on switch day. Run CI apart from the forge, and keep one check where a forge outage can’t silence it.

Port every workflow switched off

By the switch, 29 workflows in the monorepo and 9 in the GitOps repo, signed Apple builds included, had been ported, and each ran on the forge, switched off, before it could do anything. Almost every job carries a gate like this:

jobs:
  writeback:
    if: vars.ENABLE_FORGE_STAGING_IMAGE_WRITEBACK == 'true'

An unset variable skips the job without turning it red, so “deploy from the forge” became an instance setting, not a code change, and every writer got a kill switch. A separate check failed when a workflow existed on only one side, or when a pull request changed the GitHub copy without the forge copy.

Takeaway: gate every job that deploys, pushes or publishes before porting it, and make a missing twin, or a change to one side only, fail CI until you switch.

Green can mean skipped

Our forge PR pipeline looked green until a colleague opened a one-line docs change and every job went red. Every earlier forge PR had been a release-please PR, which our workflow skips, so nothing after mise install had ever executed. Behind it sat gaps: forgejo-runner doesn’t fill the strategy context, so our test shards came out as Test (shard 1/) and vitest died on --shard=1/.

Takeaway: open an ordinary PR early and read the step logs until you’ve seen every step run, matrix jobs especially.

Keep tokens away from the runner’s names

On a Forgejo runner, GITHUB_TOKEN and FORGEJO_TOKEN both hold the automatic job token. Our release bot did this:

jobs:
  release-pr:
    env:
      FORGEJO_TOKEN: ${{ secrets.RELEASE_PAT }}

The runner applies workflow and job env, then overwrites both names with the automatic token, and only then applies step-level env. The PAT was silently replaced, so the bot pushed as the automatic identity and branch protection rejected it. Rotating the PAT did nothing. The clue was its updated_unix timestamp, which Forgejo bumps on every use and which never moved.

Takeaway: pass PATs under a name that doesn’t start with FORGEJO_, GITHUB_ or GITEA_, and hand them to the tool explicitly. If a token seems ignored, check whether it is used at all.

Import beside, then rename

Forgejo’s migrator cannot import into an existing repository. So we imported full history into new repositories named <repo>-gh, verified them, and renamed: old to -old (archived), new into place. Issue and PR numbers matched GitHub’s exactly.

Takeaway: import into a side repository, verify, then swap names, so paths and numbers survive.

Make the repoint a no-op

Repointing ArgoCD changed 71 repoURL lines. ArgoCD 3.5 decides whether it has “already tried” an automated sync by comparing the current source to the last synced one. Changing every source resets that guard, so any OutOfSync app would have received a fresh automated sync, hooks included. Every Application had to be Synced and Healthy, or the cutover aborted; that precondition was the guard. In the 40 minutes before the first deliberate rollout, no new ReplicaSet appeared on any of our four clusters. That shows no Deployment rolled, not that nothing changed: hook Jobs and DaemonSets create none.

Production images moved by manifest with crane copy. Every digest stayed identical, so one PR could repoint production at the new registry: same bytes, nothing to canary. What remained to verify was the pull path: real nodes resolving, authenticating to and pulling from the new registry.

Takeaway: make an all-Synced state the precondition for repointing ArgoCD, and read “no new ReplicaSets” as evidence about Deployments only. Copy images by manifest so only the delivery path changes, then test that path on real nodes.

Distrust checks that match on names

A validator of ours that checks every ArgoCD source path exists recognised our repo by the substring amaiko-ai/gitops, which no new URL contains. After the repoint it would have reported OK while checking zero paths. A staging gate in our release tooling would, by the same flaw, have passed every release.

Takeaway: grep your scripts, validators and release gates for the old org and host names, then feed each check an input that must fail.

What we’d tell you before you start

  • The repositories are the easy part. Map the registry, GitOps sources and pushing bots before picking a date.
  • Port dark. One variable per writing job makes go-live a setting.
  • Green proves nothing until an ordinary PR has run every step.
  • Never put a PAT in a job-level FORGEJO_ or GITHUB_ variable. The runner wins, silently.
  • Change hosts without changing what runs. Synced apps before the repoint, identical image digests after it.
  • Any check keyed on a URL passes vacuously after the move until you’ve watched it fail.
  • Keep one watcher outside the failure domain you just made bigger. Ours still runs on GitHub.

We build amaiko, an AI colleague for Microsoft Teams.