;

Moving Repos off GitHub

2026-10-11

The first time I left Microsoft, I replaced my Android with an iPhone and my Windows PC with a Mac. A few years later, I was sick of Apple and switched back. When I later left Automattic, I moved my blog from WordPress to Hugo, and eventually to this custom monstrosity. I never went back to WordPress. To mix metaphors: after drinking the Kool-Aid for a few years, it’s a good idea to step outside the box and see what else is out there.

I left GitHub a few months ago, so I’m continuing the tradition and breaking out of that ecosystem as well. Fortunately (or unfortunately), there are many options to choose from. Bitbucket, GitLab, Gitea, Forgejo, and Codeberg were among those I considered.

If you don’t feel like reading this whole post, I’ll cut straight to the setup I ended up with:

  • Forgejo running on a home server (Mac mini) with Docker Compose
  • Remote access through Tailscale
  • Backups generated using forgejo dump
  • Backup snapshots uploaded to S3 using restic
  • All GitHub repos migrated using REST APIs and a small script

Where / How to Host?

In the end, I decided to self-host my private repos on a Mac mini I have at home and move my public, open-source repos to Codeberg. I haven’t started the move to Codeberg yet, but I’ve been running my Forgejo instance for a few months now.

Since I’m only self-hosting my private repos, I don’t need to expose the server to the public internet. I still want to be able to access them when I’m travelling or at a coffee shop. Thanks to Tailscale, that’s an easy problem to solve.

But What if the Server Fails?

The possibility of a hardware failure is real, and of course, I can’t be losing all my hard work. I considered using a cloud-based VM and database to make Forgejo more resilient and fault-tolerant. However, I really liked the simplicity of running this server as a single Docker container, with its state saved directly on disk.

This is where I made a distinction between my availability and durability requirements. I want high durability: I don’t want to lose data. But if I’m honest with myself, I’m much more flexible when it comes to availability. So what if I lose access to my repos for a few hours? As long as I can get my data back, that downtime is usually an acceptable inconvenience.

With those requirements clarified, I decided to keep running Forgejo on my single machine and back it up to S3. My Mac mini might be available, for example, 98% of the time. But my backups are stored in S3 with its 99.999999999% durability, for a few cents per GB per month.

Running Forgejo

Getting Forgejo up and running on a server that has Docker is very straightforward. The official docs have a template to get you started. I made a couple of small tweaks in my setup:

First, the official example mounts Forgejo’s data directory from a host directory. On my macOS host, though, I noticed Forgejo hanging sometimes. A little research revealed that this is a common issue with Docker on macOS. The recommendation is to mount a Docker volume for the data directory instead. The Docker volume survives container restarts and isn’t wiped unless explicitly removed, so it offers persistence without the filesystem performance penalty.

The second change I made was to mount a local backup directory at /backups in the container. This is where I export Forgejo backups before uploading them to S3.

So my Docker Compose file looks something like this (note that SSH is mapped to port 222 to avoid conflicting with macOS’s built-in SSH daemon):

 1networks:
 2  forgejo:
 3    external: false
 4services:
 5  server:
 6    image: codeberg.org/forgejo/forgejo:16
 7    container_name: forgejo
 8    environment:
 9      - USER_UID=1000
10      - USER_GID=1000
11    restart: always
12    networks:
13      - forgejo
14    volumes:
15      - forgejo-data:/data
16      - ./backups:/backups
17    ports:
18      - '3000:3000'
19      - '222:22'
20volumes:
21  forgejo-data:

Remote Access

I installed Tailscale on the Mac mini to make it easy to access the machine by its hostname, even when I’m not at home. The setup is simple. I recommend tagging your server and granting it the minimum permissions necessary. There’s no need for your server to be able to access all machines on your tailnet, or vice versa.

Backup Script

Each dump is about 3 GB, which can add up quickly if you’re running backups frequently. I solved that problem by using restic instead of uploading a full zip file each time. It compares the data against what’s already stored and uploads only what’s new. A code forge, where much of the data stays identical from day to day, is a great fit for this approach. The result is smaller, faster uploads. Each snapshot still gets its own tag, so you can easily restore to any point in time you’ve kept.

The most important parts of my backup script are:

1# Run forgejo dump to a directory mounted from the host machine
2docker compose exec -u 1000:1000 -T server forgejo dump --file "/backups/${ZIP_NAME}"
3
4# Unzip the backup so restic can do its diff magic
5unzip -q "${ZIP_PATH}" -d "${RESTIC_DIR}"
6
7# Use restic to back up to S3
8restic backup "${RESTIC_DIR}" --tag "${BACKUP_TIMESTAMP}"

Migrating the Repos

I was pleasantly surprised by how easy migrating from GitHub to Forgejo was. Forgejo can automatically import GitHub repositories, and more impressively, it brings EVERYTHING over, not just the Git repo. That means issues, pull requests, comments, conversations, and even release packages all moved over to Forgejo automagically.

The one caveat was that the UI only lets you import one repo at a time. With over 100 repos on GitHub, spread across a few orgs, I was not about to import all of them manually. That’s where the GitHub and Forgejo APIs come into play. I wrote a little C# program that lists all the repos from my GitHub account and then triggers an import for each one via the Forgejo API.

I’ll leave it to you (or your AI) to figure things out in your favorite language, but to get you started, here’s the flow in pseudocode. The GitHub requests use GitHub authentication, and the Forgejo requests use Forgejo authentication. The auth_token in the import body is the GitHub token Forgejo uses to read the source repo.

 1# List repos owned by the authenticated GitHub user
 2url = "https://api.github.com/user/repos?affiliation=owner"
 3
 4# Or, to list an organization's repos instead:
 5# url = "https://api.github.com/orgs/{org}/repos"
 6
 7repos = []
 8while url exists:
 9    response = GET url
10    stop on an unsuccessful HTTP status
11    append each repo's "full_name" from the JSON response to repos
12    url = URL with rel="next" in the response's Link header
13          (or none if there are no more pages)
14
15# Import each repo into Forgejo
16for each repo in repos:
17    split repo ("owner/name") into owner and name
18
19    POST https://{forgejo-host}/api/v1/repos/migrate
20    Authorization: token {forgejo-token}
21    Content-Type: application/json
22    Request timeout: 15 minutes
23
24    JSON body:
25    {
26        "service": "github", "repo_name": "{name}",
27        "repo_owner": "{owner}",  
28        "clone_addr": "https://github.com/{repo}.git",
29        "auth_token": "{github-token}",
30        "private": true, "wiki": true, "issues": true,
31        "labels": true, "lfs": true,
32        "milestones": true, "pull_requests": true,
33        "releases": true
34    }
35
36    check the response for success before continuing

Experience So Far

So far, so good!

  • Git operations and the Forgejo UI are blazing fast when I’m at home
  • It’s almost as fast from a nearby Starbucks
  • I look forward to testing the performance the next time I travel
  • Self-hosting a service most of us take for granted has been a great learning experience
  • While GitHub is great, I like not being too dependent on a corporation’s generosity

What’s Next?

The big things still on my list:

  • Figuring out CI/CD, probably with Woodpecker
  • Moving the public repos to Codeberg
  • Updating the GitHub repos to make the move official