Dispatch No. 002
GitLab raised the rent and called it AI. So we moved 700 repos out in one weekend.
The renewal quote landed like a hostage note.
GitLab had raised the price, refused to negotiate, and — this was the part that stung — forced a minimum license count we didn’t need. The justification? They’d shipped a pile of new AI features. We hadn’t asked for the AI features. We were now paying for them anyway. That was the forcing function. Not a strategy deck, not a cloud-native north star. A bill we didn’t want to sign.
So we decided to leave. Two DevOps engineers, roughly 700 repositories serving both the software and data factories, one weekend, and a hard constraint: minimal friction for the teams, and everything had to work on Monday morning.
Both ends were on-prem, on our corporate Nutanix. The destination: a single-node microk8s cluster running the Gitea Helm chart. In hindsight, “single node” is doing a lot of foreshadowing in that sentence.
The plan, such as it was
Friday evening, GitLab went into maintenance mode to freeze writes. Comms went out to every team: don’t push, we’ve got it, see you Monday.
A bit of searching turned up gitlab2gitea.py and friends — scripts that move repos over the API. It worked, mostly. The first crack appeared early: groups and subgroups don’t map cleanly to Gitea. GitLab’s nested group model and Gitea’s flatter org model are not the same shape, and no script papers over that for you. We started keeping a list of “things that did not translate.” It got long.
The mess was CI. The mess is always CI.
Moving git history is the easy part. Everyone learns this the hard way.
Every .gitlab-ci.yml that shipped code to production had to be rewritten into Gitea Actions — different syntax, different behaviour. We looked for a converter the way you look for your keys when you’re already late. A script to turn GitLab CI into Actions. There wasn’t one that worked. There never is.
So we did the thing that was either over-engineering or platform engineering depending on the Monday:
We stopped porting pipelines one by one and started building primitives — reusable templates abstract enough to cover every repo that builds and ships to prod.
We pulled Dockerfiles out of the individual repos into a central config repo, then classified them by stack: java Dockerfiles, node Dockerfiles, and so on. We standardised branches. We wrote one generic myapp-chart Helm chart whose templates could express every deployment strategy we had at the time, so a team didn’t get a bespoke pipeline — they got the paved road.
That decision saved the weekend. It also quietly turned two DevOps engineers into a platform team, which is a different blog post.
Meanwhile the long tail of “and also” kept arriving:
- LFS had to be reconfigured on the Gitea side from scratch.
- Submodules needed re-wiring.
- Artifact storage got migrated by hand.
- SSH git push had to be reconfigured — and Gitea sat behind HAProxy, so we got to enjoy SSH forwarding through a load balancer, which is exactly as fun as it sounds.
- CI/CD secrets as a first-class concept basically didn’t exist in Gitea the way they did in GitLab.
- Group permissions — see above, they didn’t translate.
The thing we didn’t see coming was the Data team
The software factory we could reason about. The data factory had a dependency we hadn’t mapped: their pipelines ran on Google Cloud Build, triggered by GitLab’s native integration on push. Gitea had no equivalent trigger integration. Their entire build path assumed GitLab existed.
The fix was ugly and I’m not going to pretend otherwise. We stood up a shadow GitLab Community instance, and wrote a CI job that cloned and pushed the data repos to it on every change — purely so Cloud Build’s GitLab trigger would keep firing.
The solution worked. At what cost.
We left GitLab to save money, and the first thing the migration made us do was run a second GitLab.
Then Gitea started falling over at peak
It would just go down when load was highest. We chased it through three layers:
- Linux file descriptors — Gitea indexing large repos was exhausting them.
- Postgres — the HA setup was re-electing a new master every two seconds. (If that sounds familiar, it’s the same class of failure that ate two of our three replicas.)
- Redis — the HA Helm chart was re-electing its write instance every couple of seconds too, or failing liveness probes for reasons that never fully made sense.
By the third one we were openly asking whether a single-node microk8s was ever the right home for this workload. We never got a clean answer. We got it stable enough for Monday, and “stable enough for Monday” is its own kind of engineering.
Two gotchas that nearly undid the whole thing
The migration tool lied about being done. gitlab2gitea didn’t actually move the code when it returned — it kicked off a migration task that Gitea then ran asynchronously in the background. We found repos sitting on that little cup-of-tea loading spinner, “migrating,” forever. The script said success. The code was still in the old GitLab. If you trusted the exit status, you trusted a lie.
Maintenance mode got switched off — briefly. Someone disabled the write freeze for a bit of testing, a few writes slipped through to the old GitLab, and we got to resync those repos from GitLab to Gitea all over again. A freeze you can quietly disable is not a freeze.
Monday
Big bang. The gitlab. subdomain got shortened to just git., which is the most satisfying part of any migration — the old name simply stops resolving.
The devs grumbled. Gitea’s UI is not the polished thing GitLab is, and they felt it. CI/CD did its job, though the runners failed intermittently for reasons that sent us down yet another rabbit hole — which, annoyingly, made our pipelines, our caching, and our Nexus artifact setup genuinely better. And the company saved a large bill. That part was real and immediate.
What it actually cost
The weekend itself was a high. Two people, scripting, architecture, infra, the whole toolbox lit up at once — there’s nothing like a hard deadline and a clean cutover to remind you why you do this work.
But the debt kept creeping for weeks after. The shadow GitLab. The secrets we re-platformed by hand. The permission model we kept patching. The runner gremlins.
Here’s the honest ledger, and the only takeaway that matters: a managed platform like GitLab is expensive because it’s quietly doing an enormous amount of work you never see — moving code, storing it, indexing it, triggering things, keeping it safe. The renewal looked extortionate right up until we itemised our own weekend, plus the months of creep behind it.
We didn’t make that cost disappear. We moved it somewhere the invoice couldn’t see it — onto ourselves. Sometimes that’s the right trade. Just don’t tell yourself it was free.