From Gitea to Forgejo: Migrating My Git Forge and Taking Ownership of My Code
Breaking up with GitHub, one repo at a time
The introduction
I've been mirroring my GitHub repositories to a self-hosted Gitea instance for a while now. All of it synced to a server sitting in my house. It started as a backup strategy, but at some point I stopped thinking of it as a backup and started thinking of it as the real copy. My code shouldn't live solely on someone else's infrastructure.
GitHub is fine. It's convenient. It's where the community is. But it's also owned by Microsoft, subject to DMCA takedowns on repos you depend on, and one policy change away from doing something you didn't sign up for. We've watched it happen to other platforms. The smart move is to not have all your eggs in one basket, especially when the basket belongs to someone else.
So when my Gitea instance needed an upgrade, I decided to make the jump to Forgejo, the community fork that split from Gitea after concerns about its corporate direction. This is the story of that migration, including every wall I hit along the way. It's not a tutorial. It's a war story with SQL queries.
The why (Forgejo over Gitea)
Gitea started as a community project, a fork of Gogs, built by people who wanted a lightweight, self-hosted Git forge. Then Gitea Ltd was formed, and the governance shifted. The community that built the thing had less say in where it was going. Sound familiar?
Forgejo forked from Gitea in late 2022, backed by Codeberg and committed to staying community-governed. It's not a dramatic philosophical difference in the software itself. The interfaces are nearly identical. The APIs are compatible. But governance matters. I'd rather build on a project where the community can't be overruled by a board meeting.
There's also a practical angle: Forgejo is where the development energy is going. Federation support (your Git forge talking to other forges, like email servers talk to each other) is being built in Forgejo, not Gitea. I want to be on the right side of that split.
The first wall: database incompatibility
I assumed the migration would be straightforward. Forgejo is a fork of Gitea. Same codebase, same database schema, just swap the container image and restart. Right?
Wrong.
My Gitea instance was running version 1.25+, which put the database at migration version 323. Forgejo only supports migration from Gitea databases up to version 305, which corresponds to Gitea 1.22. Anything newer and Forgejo refuses to start. The schemas diverged after the fork, and there's no bridge.
ORM engine initialization attempt #1/10 failed. Error: migration version 323 is too high for this version of Forgejo
I spent time looking for workarounds. Downgrade Gitea first? The database migrations aren't reversible. Use an intermediate Forgejo version? Forgejo v10 is the documented bridge release, but it still caps at version 305. The math didn't work. My database was too far gone.
So: start fresh. New Forgejo instance, new database, migrate the data over via API.
The architecture
Here's what I was working with:
OLD (decommissioned):
/data/docker/gitea/
Gitea 1.25+ / Postgres 14
DB migration version 323
~87 GitHub mirror repos + 1 local repo
90 private infrastructure repos (mauveNET org)
14 organizations
NEW:
/data/docker/git/
Forgejo 14 (based on Gitea 1.22) / Postgres 14
Fresh database
Container name: git
Port 3030 (HTTP), 222 (SSH)
The plan was simple in theory:
- Install fresh Forgejo, configure it
- Spin up old Gitea temporarily alongside it
- Use Forgejo's migration API to pull repos from old Gitea
- Copy user accounts, SSH keys, webhooks, and settings
- Tear down old Gitea
Simple plans are how you end up debugging Docker DNS resolution at midnight.
The second wall: Docker DNS collision
To migrate repos, the new Forgejo needed to reach the old Gitea over the network. I put both on a shared Docker network so Forgejo could hit the old Gitea at gitea-old:3000.
Both compose stacks had a Postgres service named db. Seemed fine. They're on different networks. Docker DNS should resolve db to the container on the same network.
Except the old Gitea's server container was on two networks: its own internal network and the shared network with Forgejo. When it resolved db, Docker's DNS handed back the IP of Forgejo's Postgres container (on the shared network) instead of its own.
ORM engine initialization attempt #1/10 failed.
Error: pq: password authentication failed for user "gitea"
Of course the password failed. It was connecting to the wrong database server entirely. Forgejo's Postgres has a user called forgejo, not gitea. The error message wasn't lying, but it sure wasn't helping.
I spent an embarrassing amount of time changing Postgres authentication methods, resetting passwords, and toggling pg_hba.conf between scram-sha-256, md5, and trust before I thought to check what IP address db was actually resolving to.
$ docker exec gitea-old getent hosts db
172.17.0.130 db
$ docker inspect git-db-1 --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}'
172.17.0.130
$ docker inspect gitea-db-1 --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}'
172.17.3.66
Once I saw it, the fix was obvious: don't put them on the same Docker network at all. Just expose the old Gitea's port and connect via the host IP.
The third wall: migration API quirks
With the old Gitea reachable at 192.168.12.17:3031, I started migrating repos. Forgejo's migration API (POST /api/v1/repos/migrate) supports importing from Gitea as a service type, which should bring over issues, labels, milestones, and wiki along with the git content.
First problem: Forgejo blocks migrations from private IP ranges by default. Reasonable for a public instance, annoying for a homelab.
{"message": "You can not import from disallowed hosts."}
The fix is a config setting. The documentation says ALLOW_LOCAL_NETWORKS. The actual setting is ALLOW_LOCALNETWORKS (no underscore). That one cost me fifteen minutes of staring at a config file that looked correct.
[migrations]
ALLOW_LOCALNETWORKS = true
Second problem: the gitea service type migration tried to fetch pull requests from the source. The repo I was migrating didn't have PRs enabled. The API returned 404 for the PR endpoint, which the migration code treated as a fatal error, rolling back the entire operation. Git content was cloned, then deleted. Issues were imported, then orphaned.
MigrateRepository: error while listing pull requests (page: 1, pagesize: 49). Error: not found
The workaround: migrate in two passes. First, use service: "git" (plain git clone) to get the repository content. Then use a script to copy issues, labels, and comments over via API.
Third problem: private repos need authentication. The migration API accepts an auth_token parameter, but I initially missed it. Ninety repos failed with "could not read Username" before I realized what was happening.
The migration script
For the one local repo that had 349 issues, I wrote a Python script that:
- Fetched all labels from old Gitea, created them on Forgejo
- Fetched all issues sorted by number (ascending, to preserve ordering)
- Created each issue on Forgejo with matching title, body, and labels
- Copied all comments for each issue
- Closed issues that were closed on the source
It's not elegant, but it moved 349 issues with zero errors. The issue numbers won't match (Forgejo assigns them sequentially), but the content is all there.
For the 90 mauveNET infrastructure repos, it was simpler: plain git migration with auth token, no issues to worry about. A loop, a curl call, and a progress counter. All 90 migrated cleanly.
User migration
Creating the user account was easy enough via the admin API. The tricky part was preserving the ability to log in with the same password.
Gitea stores passwords as salted hashes. The user table has passwd, passwd_hash_algo, and salt columns. Forgejo uses the same schema. So instead of setting a new password, I:
- Created the user via API with a temporary password
- Directly updated the Forgejo database with the original hash, salt, and algorithm from the old Gitea database
UPDATE public.user SET
passwd = '17f043a090b...',
passwd_hash_algo = 'pbkdf2$50000$50',
salt = '173442a0a173d0d...'
WHERE lower_name = 'mauvehed';
Same password, same hash. No password reset email needed. The PASSWORD_HASH_ALGO = pbkdf2 setting in Forgejo's config ensures it can verify the old format.
SSH keys migrated cleanly via the admin API. The avatar was a file copy between container volumes plus a database update. OAuth2 applications (git-credential-oauth, Git Credential Manager, tea) already existed in Forgejo with matching client IDs, created by default.
The global Discord webhook recreated via the admin hooks API. One webhook, every event type, firing notifications into a Discord channel. Took one curl call.
The settings
Most of Forgejo's settings carried over from the old Gitea's app.ini. The ones that mattered:
APP_NAME = mauveGIT
LANDING_PAGE = explore
PASSWORD_HASH_ALGO = pbkdf2
DEFAULT_ENABLE_TIMETRACKING = true
ENABLE_OPENID_SIGNIN = true
ENABLE_OPENID_SIGNUP = true
DEFAULT_THEME = forgejo-dark
The theme situation was its own adventure. The old Gitea had custom themes (edge-dark, nord, palenight) that were CSS files in a custom directory. Those don't exist in Forgejo. I had to check which themes actually ship with the binary:
for theme in forgejo-auto forgejo-light forgejo-dark gitea-auto gitea-light gitea-dark; do
code=$(curl -sL "http://localhost:3030/assets/css/theme-${theme}.css" -o /dev/null -w '%{http_code}')
echo "$theme: $code"
done
Six themes exist. arc-green doesn't. The old custom themes don't. Set the default to forgejo-dark and moved on.
The bigger picture
Here's the part that isn't about Docker containers and SQL queries.
Pretty much everything I've written lives on GitHub. All of it sitting on servers I don't control, owned by a company that answers to shareholders, not developers.
GitHub is convenient. It's also a single point of failure for a scary amount of open-source software. And convenience is how they get you. You put your stuff there because everyone else is there, and then one day you realize you can't leave without losing something.
I run gitea-mirror (now pointed at Forgejo) to sync all my GitHub repos to my local instance. All repos, all branches, automatically. If GitHub disappears tomorrow, if Microsoft decides to paywall private repos, if they start training AI on my code in ways I didn't consent to (oh wait), my code is still on a server in my house.
But I'm starting to think about flipping the relationship entirely. Make the Forgejo instance the source of truth. Push to GitHub as the mirror, not the other way around. Or maybe stop pushing to GitHub altogether.
The fediverse model works for social media (Mastodon, Pixelfed). It works for communication (Matrix). Forgejo is actively building federation support for code hosting. Your Git forge talking to other forges, pull requests across instances, like email but for code. No central authority needed, just protocols.
We're not there yet. But it's getting closer.
What I learned
Check database migration versions before planning an upgrade. Gitea 1.23+ is a one-way door. If you're running Gitea and thinking about Forgejo, migrate before you upgrade past 1.22.
Docker DNS is not as isolated as you think. Containers on multiple networks can resolve service names from any of them. When two stacks share a network and both have a service named db, you're going to have a bad time.
Read the config setting names carefully. ALLOW_LOCAL_NETWORKS is not ALLOW_LOCALNETWORKS. An underscore can cost you an hour.
Migration APIs are fragile. If the source doesn't have a feature (like PRs), the migration may fail entirely instead of skipping gracefully. Test with one repo before scripting ninety.
Direct database manipulation is sometimes the right call. Password hashes, avatar references, theme settings. Sometimes the API doesn't expose what you need and a targeted SQL UPDATE gets it done faster than any workaround.
Data ownership isn't paranoia. Redundancy is a best practice for systems. Apply it to your data too. If your code only exists on one platform you don't control, you don't really own it.
The epilogue
The old Gitea data is still on disk. I'll keep it for a while, just in case. But the containers are stopped and the new Forgejo is running.
Eighty-seven GitHub mirrors sync automatically via gitea-mirror. Ninety infrastructure repos migrated with their git history. One local project moved over with all 349 issues. User accounts, SSH keys, webhooks, org memberships, avatars: all transferred. The Discord webhook fires on every push, just like before.
It took longer than expected. These things always do. But the forge is mine now, running on software I chose, on a box in my house. GitHub gets a copy when I feel like giving it one.
Have questions or want to discuss? Find me on the fediverse:
- Matrix: @mauvehed:takeonme.org
- Mastodon: @[email protected]
- Feddit: [email protected]
- Pixel: [email protected]