entry-41
Forgejo as primary, GitHub as a mirror
All 21 homelab repos now live on self-hosted Forgejo, with GitHub as a push mirror and a pull mirror on a cloud VM. Private GitHub repos cannot be locked without Pro, and the 443 hang was a WSL2 path MTU blackhole.
revisions rev. 1 first set 2026-10-23 last revised 2026-10-23 5 min read
I wanted the git host I push to on a machine I run, and I wanted GitHub to keep a copy. Those are different jobs.
Forgejo is primary. The code I develop against lives there. GitHub is a push mirror. Public mirrors stay visible as portfolio. Private mirrors are off-site git backups, history that is not only on the homelab or the laptop. A cloud VM also pull-mirrors Forgejo, so there is a second copy on infrastructure I control. The Forgejo host is backed up with the rest of the lab.
The host is its own small VM, with its own upgrades, credentials, and restore path.
Migration was one repo at a time: private repos first, public repos last. Each repo had to show three things before I stopped pushing to GitHub by hand: a Forgejo copy, a GitHub push mirror, and a pull mirror on the cloud VM. Mirror pushes continue after that. Locking GitHub against a direct push turned out weaker than the plan.
A disposable test repo proved the mirrors first. Forgejo pushed a commit to GitHub on its own, and the commit SHA matched on both sides. The cloud VM cloned a mirror over my tailnet and had both commits. I deleted the test repo on both hosts after that.
The pattern, per repo
History moved as a mirror clone, then a mirror push:
git clone --mirror <github-url> repo.git
git -C repo.git push --mirror <forgejo-url>
--mirror carries branches and tags, not whichever branch I happened to
have checked out. I compared Forgejo’s branch list to GitHub’s live
list. Stale branch refs in old local checkouts stayed behind, because
those branches were already merged and deleted on GitHub. The mirror
clone follows GitHub, not the laptop.
Forgejo rejected refs/pull/*. That is expected. GitHub creates those
refs for pull requests. They are not branches I created. Skipping them
does not drop history.
The credential that pushes back to GitHub is not my main account. A dedicated bot account holds it, with write access on the mirrored repos. Forgejo’s push mirror authenticates as that bot, syncs on commit, and runs again about every eight hours in case a commit hook was missed.
The cloud VM keeps a bare mirror clone per repo, each with its own systemd user timer. The timers are staggered so the fetches do not land together. On the early repos I started each timer by hand and checked the commits before calling that repo done.
On a working checkout, origin points at Forgejo and github points at
the mirror. A normal push goes to Forgejo first. Forgejo copies it to
GitHub.
One script did the rest: create the repo on Forgejo, mirror it, check the branch list, install the push mirror, add the timer. Two bugs in that check looked like data loss.
Forgejo fills its branch table asynchronously after a large push. The verify step ran at once, read an empty list, and failed. The branches showed up a few seconds later. The check now waits, up to about a minute, instead of treating an empty table as a mismatch.
The other failure was sort order. The two lists were the same set and
compared unequal because each side had been sorted under a different
locale. Both sides now sort with LC_ALL=C. A mismatch after that means
the refs actually differ.
GitHub Pro, and the lock I did not get
I wanted GitHub to refuse a direct push. I tried Rulesets and classic branch protection on a private repo. Both returned the same 403: upgrade to GitHub Pro, or make the repository public, to enable this feature. I saw it from the API and from the web UI.
Pro is what turns either feature on for a private repo on a personal account. I am not buying it. The private repos stay private, so neither control can be enabled there. The rule is convention only. I push through Forgejo, and the mirror updates GitHub. Nothing on GitHub stops a direct push to those private repos. Habit is the enforcement, and that gap is accepted.
Names, a bundle, then the real 443 bug
MagicDNS is on across my tailnet. The cloud VM still could not resolve
tailnet hostnames. Its resolver was not wired to Tailscale’s DNS. I
added the Forgejo hostname to /etc/hosts on that VM, and the pull
mirror could reach the server. The systemd-resolved integration is still
unexplained. The hosts entry is what keeps those pulls running.
The workstation also could not open TCP 443 to Forgejo. Other machines
on my tailnet could. While that lasted, commits moved through the cloud
VM as a relay. That host can reach Forgejo and already holds the
pull-mirror clone. I made a git bundle of the delta, copied it to the
relay, fetched it into that bare clone, and pushed from the relay to
Forgejo. The push mirror copied the same commits to GitHub. The SHA
matched on both sides.
The hang was an MTU blackhole on the WSL2 path to the LAN. A DF-bit sweep found the ceiling at about 1214 bytes. Larger packets were dropped with no ICMP, while the interface still advertised a bigger MTU. I set that interface to MTU 1200 and persisted it. Direct access from the workstation works, and the relay is retired.
Pushes from the workstation now go over SSH, using a key I already had. No token is stored on this host. The token files used during migration are gone.
Where this stands
All 21 repos are on Forgejo as primary: 14 private, 7 public. The public ones were created public on the self-hosted side on purpose. Sign-in, and the server sitting on my tailnet, is the access boundary. I did not use the repo flag to hide them.
Each repo has one GitHub push mirror from the bot account, and a pull
mirror on the cloud VM with its own timer. Forgejo and GitHub hold the
same 21. Local checkouts use Forgejo as origin and keep github as
the mirror remote. GitHub is a copy. On the private repos, it is not
locked.
Related posts: Protect the branch where the pushes actually land, The HTTPS request that hung while ping worked, Rotating the tokens behind 21 mirrors.