Migrating 29 repositories onto self-hosted CI/CD
Twenty-nine repositories and their pipelines onto infrastructure the client controls, with the two failure modes that cost the most time written down.
A team's entire source history, branch structure and build pipelines sat on a hosted service reachable over a network path they did not control and could not rely on. The requirement was not a backup. It was to make the hosted service optional: every repository, every branch, every tag and a working pipeline for each one, running on hardware the team owns.
The requirement was not “back up the repositories”
A team can back up repositories with a cron job. The requirement here was different and harder: make the hosted service optional. Every repository, every branch, every tag, and a pipeline that actually builds, all running on hardware the team owns and can reach.
Twenty-nine repositories. A destination of self-hosted Gitea 1.23 with act_runner for continuous integration, both in Docker. Two days.
Most of the work was ordinary. Two findings were not, and they are the reason this case study exists.
Finding one: the push that never fails
The default way to push a repository to a new server is over HTTPS with a token. It is what every guide shows.
On this network path, it hung. Not an error. Not a timeout. Not a partial transfer with a resumable state. A connection that opens, transfers, and then simply never finishes — on a path that throttles and inspects traffic, where a large POST body is the shape of request that gets stuck.
"Why did the first repository take four hours and the second one never finish?"
The failure mode is expensive precisely because it is silent. A push that returns an error gets debugged immediately. A push that hangs gets waited on, then retried, then waited on again.
Switching the transport to SSH moved the same bytes over different framing, and it completed. Every subsequent repository went over SSH.
If you are moving git history across a constrained, inspected or throttled path: try SSH before you spend a day tuning HTTP buffer sizes.
Finding two: never push refs/pull/*
The instinct when migrating a repository is to be thorough — mirror everything, lose nothing. That instinct produces a --mirror push, and a --mirror push carries refs/pull/* with it.
Do not do that. The destination server generates and manages pull-request refs itself, as part of its own model of what a pull request is. Pushing another system’s pull refs into that namespace gives two writers the same refs and leaves state on the destination that is tedious to unpick and confusing to everyone who looks at it afterwards.
The correct push spec is narrow and deliberate:
- refs/heads/* — every branch. This is the history.
- refs/tags/* — every tag. This is the release record.
- Nothing else. Pull-request refs are the source system's bookkeeping about its own review process, and the destination has its own.
The engine
The migration is driven by a tab-separated manifest, one row per repository, carrying source, destination and state. Every stage is resume-safe: re-running the engine after a failure picks up exactly what is left rather than starting again.
That design is not sophistication, it is arithmetic. Twenty-nine repositories over an unreliable path means partial failure is the expected case. A one-shot script converts any single failure into a full restart, and on a slow path a full restart costs a day.
The same manifest doubles as the record of what was migrated and what was verified afterwards — which matters more than it sounds, because “did repository 19 actually get its tags” is the question you cannot answer from memory at the end of day two.
The organisation, its users, its teams and the runner registration are all created by script rather than through the web interface. Twenty-nine repositories of hand-configured permissions cannot be reviewed and cannot be rebuilt.
What this is worth to a buyer
Two things.
First, the capability itself: source control and continuous integration can be moved onto infrastructure you control, in days rather than quarters, with the pipelines translated rather than abandoned.
Second, and more useful: the two findings above are not in any vendor’s migration documentation, because they only appear on network paths that vendor documentation does not assume. If you operate on one of those paths, you will hit both. Now you have them for free.
The honest trade
Self-hosting does not remove the operational problem, it relocates it. The runner needs capacity, the server needs upgrades, and the whole thing needs a backup drill that has actually been rehearsed. A self-hosted platform nobody has ever restored from is a worse position than the hosted service it replaced. That should be part of the decision, not a surprise after it.
Decisions
Push over SSH, not HTTPS.
Alternatives: Git over HTTPS with a token
HTTPS is the default, it is what every guide assumes, and on this network path it silently did not work. Large POST bodies — which is what a full history push is — hung indefinitely. Not an error, not a timeout, not a partial transfer: a connection that stays open and never completes, on a path that throttles and inspects traffic. SSH moves the same bytes over a different framing and completed. This is the single finding that saved the most time, and it is worth trying first on any similarly constrained path.
The failure is silent, which is what makes it expensive. A push that errors gets debugged in minutes. A push that hangs gets waited on.
Push only refs/heads/* and refs/tags/*. Never refs/pull/*.
Alternatives: Push --mirror, or push refs/*
A mirror push looks like the thorough option and it is the wrong one. The destination server generates and manages pull-request refs itself as part of its own data model. Pushing the source system's pull refs into that namespace puts two writers on the same refs and produces confusing, hard-to-unpick state on the destination. Branches and tags are the history. Pull-request refs are the other system's bookkeeping.
Drive the migration from a manifest, and make every stage resume-safe.
Alternatives: A single script iterating over a list of repositories
Twenty-nine repositories over an unreliable network path means partial failure is the expected case, not the exception. A one-shot script turns any single failure into a full restart, and a full restart on a slow path costs hours. A tab-separated manifest with per-repository state means a re-run picks up exactly what is left, and the same file is the record of what was migrated and what was verified.
Self-host the platform rather than move to another hosted service.
Alternatives: A different hosted git and CI provider
The problem being solved was dependence on a network path outside the team's control. Moving to a second external provider changes the vendor and reproduces the failure class exactly. Self-hosting converts a reachability problem into an operations problem, which is a trade the client can actually manage.
Bootstrap the organisation, users, teams and runner from a script.
Alternatives: Configure them through the web interface
Twenty-nine repositories of hand-clicked permissions cannot be reviewed, cannot be repeated, and cannot be rebuilt after a restore. A script that creates the org, the users, the teams and the runner registration is documentation that executes.
Limitations
- Only branch and tag refs were pushed. Pull-request refs belong to the destination server's own data model, so pull-request discussion history from the source system did not travel with the repositories. That history stays where it was made.
- A translated pipeline is a rewrite, not a port. The two systems do not share semantics for triggers, variables, agents or service containers, and no automatic equivalence is claimed between the original definition and the workflow that replaced it.
- Self-hosting moves the burden rather than removing it. Runner capacity, upgrades, backups and disk are now the client's responsibility, and a self-hosted platform with no backup drill is a worse position than the hosted service it replaced.
- The SSH finding is specific to a constrained network path. It is not a general claim that git over HTTPS is broken, and on an ordinary network the same push works fine.
- No per-repository timings, transfer volumes or pipeline runtimes were captured during the migration. We can tell you what moved and what it cost in decisions, not how many megabytes per second it moved at.