Migrating Away From GitHub
Almost three years ago, in the Migrating to Localhost article, I mentioned that I moved all of my private repositories to GitHub because, hey, it’s free for personal use and someone else gets to worry about keeping it running. Well, as it turns out, “someone else worrying about it” is exactly the problem.
This is the story about how I moved all of my repositories to a self-hosted Forgejo instance running in my home Kubernetes cluster, and why it took me this long to flip the final switch.
Why leave?
Two reasons really.
First one is reliability. GitHub has been having more and more outages lately, to the point where it became a running joke. Depending on who is counting, there were over 250 incidents in the last 12 months, with February this year being the worst month on record. One look at the unofficial GitHub status history tells the whole story — it is about as green as my grass when I overmow it in the middle of the summer. GitHub Actions being the most affected service is extra annoying because that’s what builds and deploys half of my things, including this very blog. When your CI/CD pipeline availability depends on someone else’s “we are experiencing degraded performance” status page, you start reconsidering your choices.
Second one is more fundamental. Earlier this year we got a nice reminder that code hosted on someone else’s platform is only yours as long as they say so, when a botched mass DMCA takedown nuked thousands of repositories belonging to completely innocent developers. Most of it got restored eventually, but “eventually” is doing a lot of heavy lifting in that sentence. I don’t want to wake up one day and find out my repositories are gone because some automated system somewhere had a bad day.
Self-hosting my own git server is not exactly a new territory for me. As mentioned in that same article from 2023, I used to run a GitLab instance for years. So this is less of a brave new adventure and more of a return to the roots.
Forgejo
The software choice was the easy part. I have been running Gitea, and later Forgejo , for years now in one form or another. Forgejo is a community-driven fork of Gitea, it powers Codeberg , it is lightweight, and it has built-in mirroring and CI (Forgejo Actions) which is almost drop-in compatible with GitHub Actions. Perfect fit.
The instance runs in my K8s cluster at home. No Helm chart, just plain manifests, because for a single-instance deployment I really don’t need the added complexity. The interesting parts of the Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
namespace: forgejo
name: forgejo
spec:
replicas: 1
strategy:
type: Recreate
template:
spec:
volumes:
- name: data
persistentVolumeClaim:
claimName: data
containers:
- image: codeberg.org/forgejo/forgejo:15.0.4
name: forgejo
volumeMounts:
- mountPath: /data
name: data
env:
- name: FORGEJO__server__ROOT_URL
value: "https://git.mydomain/"
- name: FORGEJO__database__DB_TYPE
valueFrom:
secretKeyRef:
name: forgejo-credentials
key: db-type
# ... rest of the database credentials come from the same secret
Forgejo can be configured entirely through environment variables using the FORGEJO__section__KEY naming convention, which maps directly to its ini configuration file. Very handy for K8s deployments as there’s no need to manage the configuration file inside the data volume.
Database is PostgreSQL, but it doesn’t run inside the cluster. I have a separate database machine on the network, and I just point Forgejo at it through an ExternalName service:
apiVersion: v1
kind: Service
metadata:
name: db
namespace: forgejo
spec:
type: ExternalName
externalName: pgsql.mydomain.
The repositories themselves live on a PVC backed by Mayastor, and the whole thing gets backed up regularly, because a git server without backups is just a countdown timer.
For CI I also deployed the Forgejo Actions runner into the same namespace. It runs with a docker-in-docker sidecar (a privileged init container with restartPolicy: Always, the “native sidecar” pattern) and picks up jobs from the instance over the internal service. Nothing fancy, but it happily builds everything I throw at it.
Speaking of things running in the cluster, I also run Renovate there, which regularly opens PRs for application updates — including for Forgejo itself. Which means the repository holding my cluster manifests now lives on the very instance those manifests describe. If a Forgejo update ever breaks Forgejo, the PR that broke it and the manifests to fix it are both behind a service that’s down. I’m aware this circular dependency is somewhat of a risk, but the repositories are still fully cloned on my machines, so rolling back or fixing things with a plain kubectl apply from a local checkout is always an option. A risk I can live with.
The mirroring period
Now, I didn’t just yolo-migrate everything over a weekend. Since the instance was already there, it has been quietly mirroring all of my GitHub repositories as a backup — long before I had any concrete plan to leave. Forgejo has a really nice repository migration feature which, besides one-time imports, can set up a proper pull mirror. You give it a GitHub token, it clones the repository together with issues, labels, and releases, and then keeps syncing on an interval.
This gave me:
- an always up-to-date local copy of everything, so the “GitHub deletes my code” scenario stopped being scary
- confidence that the setup is stable
- a zero-pressure migration, since nothing depended on it yet
This is honestly the approach I would recommend to anyone thinking about leaving. Mirror first, live with it for a while, migrate when it stops feeling like a leap. In my case the migration ended up being less of a project and more of a formality waiting for its moment.
The SSH problem
So why didn’t I switch over completely much earlier? One word: SSH.
The web UI and HTTPS git operations were a solved problem from day one. Sometime last year I transitioned the cluster entry point from the HAProxy ingress setup
to Cilium’s Gateway API implementation (a story for a separate article perhaps), so exposing the web interface was just an HTTPRoute:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: forgejo
namespace: forgejo
spec:
parentRefs:
- name: cilium-gateway
namespace: default
sectionName: https
hostnames:
- git.mydomain
rules:
- backendRefs:
- name: forgejo
port: 3000
But git over SSH is a different beast. It is not HTTP, and it is not TLS either, it’s just a raw TCP connection to port 22. And I really wanted the clone URLs to look like git@git.mydomain:ivan/repo.git. Same hostname, same IP, standard port 22. Not ssh://git.mydomain:30022/... NodePort ugliness, and not a separate LoadBalancer IP with a second DNS name that I would inevitably mistype for the rest of my life.
Until recently, Cilium’s Gateway API implementation only handled HTTP routes, so this simply wasn’t possible, and the mirroring period dragged on. Then Cilium 1.20 landed with support for the Gateway API L4 route types (TLSRoute and TCPRoute), and the last excuse evaporated.
There is one catch: Cilium refuses to mix TCPRoute and HTTPRoute listeners on the same Gateway resource. The workaround is to create a second Gateway for L4 traffic and have both of them share the same LoadBalancer IP using Cilium’s LB-IPAM sharing key:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: cilium-gateway-l4
namespace: default
spec:
gatewayClassName: cilium
infrastructure:
annotations:
lbipam.cilium.io/sharing-key: gateway-101
addresses:
- type: IPAddress
value: "10.128.0.101"
listeners:
- name: ssh
protocol: TCP
port: 22
allowedRoutes:
namespaces:
from: All
The main Gateway (HTTP/HTTPS one) carries the same lbipam.cilium.io/sharing-key annotation and the same address. As long as the listener ports don’t overlap between the two Gateway resources, LB-IPAM is happy to hand out the same IP to both. One IP, one DNS record, ports 80/443 going through one Gateway and port 22 through the other.
With the Gateway in place, routing SSH to Forgejo is a two-liner TCPRoute:
apiVersion: gateway.networking.k8s.io/v1
kind: TCPRoute
metadata:
name: forgejo-ssh
namespace: forgejo
spec:
parentRefs:
- name: cilium-gateway-l4
namespace: default
sectionName: ssh
rules:
- backendRefs:
- name: forgejo
port: 22
And just like that, git clone git@git.mydomain:ivan/repo.git works.
Network policy gotcha
Of course, nothing ever works on the first try, that would be boring. I have CiliumNetworkPolicy resources locking down every namespace, and my first attempt allowed SSH traffic from the ingress entity, same as I do for HTTP traffic coming through the Gateway. SSH connections kept timing out.
After some head scratching and staring at Hubble output, the explanation turned out to be quite logical: HTTPRoute traffic passes through Cilium’s Envoy proxy, so from the pod’s perspective it arrives with the ingress identity. TCPRoute traffic, on the other hand, is not proxied at all — Cilium just points the LoadBalancer service straight at the backend pod. Which means the traffic arrives with the identity of the actual external client, i.e. world:
ingress:
# HTTP comes through Envoy, so it has the ingress identity
- fromEntities:
- ingress
toPorts:
- ports:
- port: "3000"
# SSH via TCPRoute is NOT proxied, it arrives as world
- fromEntities:
- world
toPorts:
- ports:
- port: "22"
The switchover
With SSH working, the actual migration was pretty anticlimactic:
- converted the pull mirrors into regular repositories (one click in Forgejo repository settings)
- re-pointed the origins in my local checkouts, which thanks to the same-looking URL was mostly a matter of
sed-inggithub.com:ivantomicaintogit.mydomain:ivan - moved the CI workflows from GitHub Actions to Forgejo Actions. The syntax is intentionally compatible, so most workflows needed nothing more than moving from
.github/workflows/to.forgejo/workflows/and swapping a few actions for their Forgejo equivalents - nuked the repositories from GitHub
That last step felt strangely satisfying. After years of GitHub being the source of truth with my instance mirroring it, the roles are now what they should have been all along.
Even this very article was built and published by the Forgejo Actions runner sitting a few network hops away from me, which brings a certain satisfaction that’s hard to explain. The Hugo build and deploy setup survived the move almost unchanged.
Closing thoughts
Is self-hosting a git server for everyone? Probably not. You become your own SLA, your own backup team, and your own security department. But my code now lives on my hardware, on my network, behind my own gateway, and the only one who can delete my repositories by accident is me. Knowing myself, that’s still a non-zero risk, but at least it’s my risk.