Why I Moved My Repos Off GitHub

Once you already have a homelab running 24/7, self-hosting your own git forge stops being a hobby project and starts being the obvious call. I run OpenTofu-managed services on that box anyway, so a git server costs nothing extra in hardware. What it does cost me is three things I’d rather stop outsourcing to GitHub.

Trust I can’t verify

I don’t use Copilot, so the usual argument about it training on your code doesn’t land for me either way. What does land is the shape of the whole thing: GitHub tells me what it does with my data through a privacy statement, and I have no way to check that statement against reality. I can read that private repository content at rest isn’t used for training, and that’s it, that’s the whole basis for believing it. A sentence on a page, backed by nothing I can inspect.

That’s the actual problem. A promise about data handling from a company I can’t audit is worth exactly as much as the company’s word, and GitHub’s own word has moved before, both on what data gets used and on what “affiliates” means for who else can touch it. On my own Forgejo instance there’s no promise to take on faith. I run the database. I can see exactly what happens to a commit the moment I push it, because I’m the one who wrote the software’s config and I’m the one holding the disk.

Whoever runs the box sets the terms

That trust problem generalizes past privacy specifically. GitHub decides when the terms of service change, and I find out by reading a changelog after the fact, on their timeline. Forgejo on my own domain flips who holds that pen. Retention, access, what happens to a repo when I want it gone, all of that is a setting I control directly instead of a policy I hope stays put.

The bill that actually moved me

This is the part with a real number behind it, not a principle. GitHub’s own docs say it plainly: Actions is free on standard runners for public repositories. Private repositories get a monthly quota of free minutes, and every minute past that gets billed.

I deploy to dev and prod constantly out of private repos. Every push, every redeploy, every re-run of a job that failed for some unrelated reason chips into that quota, and once it’s gone the meter runs.

It’s not even a fixed target to plan around. A December 2025 pricing update cut hosted-runner prices up to 39% starting January 2026, a real improvement, but the same announcement tried adding a per-minute charge to self-hosted runners on private repos too, closing off the usual workaround for dodging the hosted-runner meter. Backlash got that part paused. The direction it was heading is the part worth remembering. Forgejo Actions on hardware I already run has no quota and no meter. The cost is a machine that’s on either way.

What moving actually took

The trigger that turned this from a someday project into a this-week project was wanting an agent with direct repo access, something that could open issues, review PRs, and manage a repo on its own, without me handing out a scoped GitHub token per task. That only works if I own the instance the agent is talking to.

Setup was a devenv.nix project shell (no flake.nix needed, devenv wraps Nix already) and secretspec.toml pulling the Forgejo access token from OpenBao live, at request time. The token never touches disk, and it’s one token per account reused across every project instead of a fresh one per repo. .omp/mcp.json launches the Forgejo MCP server through secretspec run, so the agent gets access without a token sitting in a config file waiting to leak.

Creating the repo was the boring part: hit the Forgejo API, swap the git remote, push.

CI is where the real afternoon went

Moving the repo took minutes. Getting deploys working through Forgejo Actions took most of an afternoon, and that’s where the actual debugging happened.

GitHub’s denoland/deployctl@v1 Action authenticates through GitHub’s own OIDC issuer only, a trust relationship Forgejo Actions can’t participate in. I dropped the Action and called the deployctl CLI directly with a token instead.

Forgejo Actions mirrors a chunk of GitHub Actions onto data.forgejo.org, so a lot of existing workflows just work, but the mirror has gaps. My workflow referenced denoland/setup-deno, which isn’t in it, so the runner failed cloning it. I skipped the Action and installed Deno with the plain deno.land/install.sh script instead, no Action reference, no dependency on what’s currently mirrored.

Then a smaller one: deployctl resolves its entrypoint against the working directory, unlike the GitHub Action’s separate root and entrypoint inputs. I had to cd dist before invoking it and point the entrypoint at a path relative to that.

Secrets needed a second tier. OpenBao handles local CLI use fine, but the Forgejo Actions runner can’t reach it, so it needs its own copy of the token stored as a repo secret, the same pattern I already use for a container registry token on another self-hosted project.

What’s still rough

Forgejo’s GitHub-Action compatibility layer covers a lot, but it’s not complete. Anything referencing an Action outside the mirror needs a fallback, usually whatever plain CLI or install script the Action was wrapping to begin with. Good to know going in rather than mid-migration.

Worth it

Two private repos have moved so far, this site and a stock management app for a small business. I run the database they live in, I set the retention policy, and my Actions minutes cost whatever my electricity bill already costs. The CI debugging took an afternoon. Staying on GitHub would have kept costing me something every month, indefinitely, for policies I can’t check.