Flipping the Mirror: Adding Forgejo-to-GitHub Push Mirrors to Gitea Mirror
Making GitHub the backup, not the source of truth
The problem
I run Forgejo as my primary git host. All my code lives there. It's self-hosted, it's mine, and if GitHub decides tomorrow that my account violates some new policy I've never heard of, I don't lose everything.
But I still want my code on GitHub. Not because I trust GitHub more, but because redundancy matters. If my server catches fire (figuratively or literally), I want a copy of everything sitting somewhere else. GitHub is a convenient "somewhere else." Free, reliable, and most people know where to find it.
The problem is that every tool in this space assumes GitHub is the source of truth. You mirror FROM GitHub TO your self-hosted instance. The whole ecosystem is built around the idea that GitHub is primary and everything else is a copy. That's backwards for my use case. I want Forgejo to be primary and GitHub to be the read-only backup.
Gitea Mirror is an excellent tool for the traditional direction. It discovers your GitHub repos, creates mirrors on your Gitea/Forgejo instance, and keeps them synced. The author told me the basics for reverse direction exist in the underlying APIs but that he has no plans to build that functionality. Fair enough. Let's figure it out ourselves.
The investigation
Before writing any code, I needed to understand what I was dealing with. Gitea Mirror is a substantial application: Astro with SSR, React, Bun runtime, SQLite via Drizzle ORM, and a surprisingly deep feature set including metadata mirroring for issues, PRs, releases, labels, and milestones.
The codebase is roughly 15,000+ lines of TypeScript spread across API clients, database models, scheduler services, and React components. It's well-structured, but it's deeply one-directional. Every layer assumes GitHub is the source and Gitea/Forgejo is the destination.
Here's how the current architecture works:
- The app discovers repos on GitHub using Octokit
- It calls Forgejo's
/api/v1/repos/migratewithmirror: true - Forgejo creates a pull mirror and periodically fetches from GitHub
- The app tracks status, handles errors, and provides a dashboard
The critical thing to understand: the app is a setup tool, not a sync engine. It configures mirror relationships via API calls, and then Forgejo handles the actual git operations on its own schedule. The app doesn't clone repos. It doesn't push code. It just tells Forgejo what to mirror and monitors the results.
This is an important architectural detail because it constrains the approach for the reverse direction.
The first wall: GitHub can't pull
My initial assumption was that reversing the direction would be symmetrical. If Forgejo can pull from GitHub, surely GitHub can pull from Forgejo, right?
No. GitHub has no equivalent to Gitea's migrate/mirror API. There is no endpoint you can call to say "hey GitHub, periodically pull from this external URL." GitHub's API lets you create repos, manage issues, handle releases, but the actual git content transfer? That requires the git protocol. You push to GitHub. GitHub doesn't pull from you.
This means the naive reversal doesn't work. You can't just swap "source" and "destination" and call it a day. For Forgejo-to-GitHub mirroring, something has to actively push code on a schedule. And if the app has to manage that itself (cloning repos, running git push, handling SSH keys, managing disk space for intermediate clones), the architecture becomes fundamentally different. The "setup tool" pattern breaks down. You'd be building an active sync service.
I was staring at a much bigger project than I'd hoped for.
The breakthrough
Then I remembered: Forgejo natively supports push mirrors.
Not just pull mirrors (which the current tool uses). Push mirrors. The API endpoint POST /api/v1/repos/{owner}/{repo}/push_mirrors tells Forgejo to automatically push a repository to a remote URL on a configurable schedule. You give it the remote address, credentials, and an interval, and Forgejo handles the rest.
{
"remote_address": "https://github.com/user/repo.git",
"remote_username": "oauth2",
"remote_password": "<github_token>",
"interval": "8h0m0s",
"sync_on_commit": true
}
This changes everything. The app can stay a "setup tool." Instead of telling Forgejo "pull from GitHub," it tells Forgejo "push to GitHub." Same architectural pattern. Same API-call-based approach. Forgejo does the heavy lifting in both directions.
No git binary needed on the app's server. No intermediate clones. No disk space management. No subprocess spawning. Just API calls.
The full push mirror API surface:
| Operation | Method | Endpoint |
|---|---|---|
| List push mirrors | GET | /api/v1/repos/{owner}/{repo}/push_mirrors |
| Create push mirror | POST | /api/v1/repos/{owner}/{repo}/push_mirrors |
| Get mirror status | GET | /api/v1/repos/{owner}/{repo}/push_mirrors/{name} |
| Delete push mirror | DELETE | /api/v1/repos/{owner}/{repo}/push_mirrors/{name} |
The status endpoint returns last_update and last_error, which means the app can monitor push mirror health the same way it monitors pull mirror health. Everything fits.
The implementation
With the push mirror API as the foundation, the project shrank from "rewrite the sync engine" to "add a direction toggle and new API calls." Still not trivial -- the codebase touches a lot of files -- but tractable. Here's what I built, in the order it shipped.
Data model
Three new columns on the repositories table:
mirrorDirection:"pull"(default, current behavior) or"push"(new)pushMirrorRemoteName: tracks the Forgejo push mirror remote name for status checks and deletionpushTargetUrl: the GitHub clone URL for push-direction repos (becausecloneUrlwill point to Forgejo)
Plus defaultMirrorDirection on the Gitea config schema so users can set a global default. All existing repos default to "pull" so nothing changed for current users. The migration is purely additive.
Push mirror API client
A new module (forgejo-push-mirror.ts) using the existing GiteaHttpClient pattern. The codebase already had a clean HTTP client abstraction with auth headers, error handling, and JSON parsing. The push mirror functions just call different endpoints with the same patterns.
The interval format was the one gotcha. Forgejo uses Go's duration format ("8h0m0s") while the app uses human-friendly strings ("8h"). A small conversion utility bridges the gap.
Mirror job routing
The existing mirror job system dispatches to mirrorGithubRepoToGitea() when you click "Mirror." The change is a branch:
if (repoData.mirrorDirection === "push") {
await setupPushMirrorToGitHub({ repository: repoData, config });
} else {
// existing pull mirror logic, unchanged
}
The setupPushMirrorToGitHub function validates the repo exists on Forgejo, decrypts both tokens, checks for an existing push mirror (idempotency), creates the push mirror via Forgejo API, stores the remote name in the database, and updates repo status.
Direction flipping
This is where it got interesting. Flipping an existing pull-mirror repo to push direction requires:
- Verifying the repo isn't mid-operation (reject if status is "mirroring" or "syncing")
- Removing the existing pull mirror configuration on Forgejo
- Creating a push mirror pointing to GitHub
- Updating the database
Step 2 was the delicate part. A repo created via /api/v1/repos/migrate with mirror: true is a mirror repo in Forgejo. Disabling the pull mirror means patching the repo settings to convert it to a regular repo. The Forgejo API supports this, but it required careful handling to avoid losing the repo content during the transition.
Forgejo repo discovery
For push mirrors, you also need to discover repos from Forgejo. Forgejo's API for listing repos (GET /api/v1/user/repos) follows the same patterns as GitHub's, so this was straightforward.
The import flow matches repos by name across platforms. If a repo exists on both Forgejo and GitHub (the common case for repos previously mirrored from GitHub), they pair automatically.
UI: per-repo and bulk controls
The UI work shipped in three layers:
Per-repo controls. Each repo in the table gets a direction indicator (arrow showing the flow) and a toggle in the actions dropdown menu. The direction filter in the repo list lets you view only pull or only push mirrors.
Global default. A Pull/Push toggle in the Configuration page sets the default direction for newly imported repos. An "Apply to all repositories" button next to the toggle retroactively applies the current default to every existing repo in a single database operation -- no N+1 API calls.
Bulk actions. When you select repos with checkboxes, "Push" and "Pull" buttons appear in the toolbar alongside the existing Mirror, Sync, and Retry buttons. Each shows a count of how many selected repos will be affected. Click and they flip, with a toast confirming the result. Works on both desktop and mobile layouts.
Scheduler integration
The existing scheduler auto-discovers new GitHub repos and sets up mirrors. For push direction, it auto-discovers new Forgejo repos and sets up push mirrors. The MIRROR_DIRECTION environment variable controls the default direction for auto-imported repos.
The challenges
Token management
Push mirrors require a GitHub token stored on the Forgejo side. The app already encrypts tokens at rest using AES-256-GCM, but now it handles two tokens for two different purposes: the Gitea/Forgejo token for API calls TO Forgejo, and the GitHub token that Forgejo uses to authenticate when pushing TO GitHub. Both tokens were already in the config (the app needs both for pull mirrors too), but the flow of which token goes where reverses. The existing encryption layer handled this cleanly -- same getDecryptedGitHubToken/getDecryptedGiteaToken utilities, just used in a different order.
The unique constraint problem
The repositories table has a unique index on (userId, fullName). If the same repo were tracked from both GitHub import and Forgejo import, you'd get a constraint violation. The solution: a repo is tracked once with a direction, not twice from different sources. Flipping direction is an update, not a delete-and-recreate.
Dealing with existing mirrors
Many users (myself included) already have repos mirrored from GitHub to Forgejo. These repos are pull mirrors in Forgejo. Flipping them to push direction means those repos need to stop being mirrors and become regular repos that push to GitHub instead. This is a destructive state change in Forgejo. The flipMirrorDirection function needs to patch the repo via PATCH /api/v1/repos/{owner}/{repo} to disable the mirror flag before setting up the push mirror. The UI blocks direction changes while a repo is mid-operation. (This ended up being one of the post-launch bugs -- see below.)
Interval format mismatch
Forgejo's push mirror API expects Go duration format ("8h0m0s"), but the app stores human-readable intervals ("8h"). The existing duration-parser.ts already handled parsing various formats. Adding Go format output was a small utility function, but getting it wrong would mean push mirrors either sync too frequently or never sync at all. A few test cases caught the edge cases early.
What I learned
Read the API docs before designing the architecture. I spent time mentally designing a git-clone-and-push sync engine before discovering that Forgejo's push mirror API makes the whole thing unnecessary. The best code is code you don't have to write.
One-directional assumptions go deep. The codebase doesn't just assume GitHub is the source in one place. It's baked into variable names, function signatures, database schemas, UI labels, and mental models. Reversing direction isn't a find-and-replace. It's a conceptual change that touches every layer.
The "setup tool" pattern is powerful. Keeping the app as a configuration tool that delegates actual git operations to Forgejo means both directions use the same lightweight architecture. No new infrastructure requirements, no new dependencies, no new failure modes.
Phased delivery matters. The implementation had clear layers: data model, API client, job routing, direction flipping, Forgejo discovery, UI controls, scheduler integration. Each layer shipped independently and delivered incremental value. The bulk actions and "apply to all" button came last, but they turned a per-repo feature into something you can use at scale.
The bugs that followed
Two issues surfaced after the initial implementation shipped.
The cleanup service was archiving repos that still exist
The app has a repository cleanup service that periodically checks whether repos in its database still exist on GitHub. If they don't, it archives or deletes them from Forgejo. Good feature. Except it was incorrectly flagging repos as orphaned for three reasons.
Fork filtering. The cleanup called the same getGithubRepositories() function used for importing repos. That function respects the skipForks config option -- if you've told the app to skip forked repos during import, it filters them out. But the cleanup service isn't importing. It's checking existence. A forked repo that exists on GitHub but isn't being mirrored should not be archived. Those are different concerns. Repos like ansible that I'd forked on GitHub were getting flagged as "no longer on GitHub" because the fork filter excluded them from the existence check.
The fix: build a separate config for existence checking with skipForks: false. Import filtering and existence verification should never share the same filter logic.
Case-sensitive comparison. The cleanup built a Set of GitHub repo full names and checked each DB repo against it using Set.has(). Case-sensitive. If GitHub returns MauveHed/repo but the database stored mauvehed/repo at import time, the lookup fails. The database already has a normalizedFullName column for exactly this scenario, but the cleanup service wasn't using it.
The fix: .toLowerCase() on both sides of the comparison. Applied to both the GitHub and Forgejo existence checks.
Org repos not fetched. The GitHub API's listForAuthenticatedUser returns repos the user owns or has access to, but it doesn't necessarily return all repos from organizations the user imported. Repos brought in via the organization sync could appear orphaned because the cleanup never called listForOrg for those organizations.
The fix: after fetching the user's repos, also fetch repos from every organization that has entries in the database.
Pull mirrors not disabled on direction flip
When you flip a repo from pull to push, the app needs to tell Forgejo to stop pulling from GitHub before setting up the push mirror. Otherwise Forgejo is simultaneously pulling from GitHub AND pushing to GitHub -- a bidirectional loop that makes no sense and can cause conflicts.
The flipMirrorDirection function was updating the database to record the new direction, but it wasn't making the API call to disable the pull mirror on Forgejo. The push-to-pull path correctly deleted the push mirror remote, but the pull-to-push path skipped the equivalent step.
The fix: before updating the database, PATCH /api/v1/repos/{owner}/{repo} with { mirror: false, mirror_interval: "" } to convert the Forgejo mirror repo into a regular repo. This preserves all content while stopping the pull sync. The push mirror setup that follows then configures the outbound sync to GitHub.
The result
The irony of this project isn't lost on me. The entire ecosystem of git mirroring tools assumes you want to back up your GitHub repos somewhere safe. But for those of us who've decided that self-hosted infrastructure IS the safe place, the tools run in the wrong direction.
Forgejo's push mirror API was the key that made this feasible without a massive rewrite. The app tells Forgejo where to push, Forgejo handles the git operations, and GitHub becomes what it should be for my use case: a backup destination that happens to have good discoverability.
Here's what shipped:
- Per-repo direction control -- flip any repo between pull and push from the actions dropdown
- Bulk direction toggle -- select repos with checkboxes, click "Push" or "Pull" to flip them in batch
- Global default -- set Pull or Push as the default for new imports in Configuration
- Apply to all -- one button to retroactively apply the default direction to every existing repo
- Direction filter -- filter the repo list to show only pull or only push mirrors
- Forgejo repo import -- discover and import repos directly from your Forgejo instance
- Scheduler integration -- auto-imported repos respect the configured default direction
- Environment variable --
MIRROR_DIRECTION=pushfor headless/Docker deployments
Nothing broke for current users. Every existing repo stayed as a pull mirror. The feature activates only when you configure it. And when you do, you can flip one repo, fifty repos, or all of them with a single click.
Lastly, I know.. I need to open a pull-request back to gitea-mirror so everyone can enjoy this feature. Why haven't I yet? Because I'm sure it needs a lot of work to be stable ready. It works for me, and that was all I wanted for right now. Hopefully, when I have more breathing room, I can properly share it with everyone.
Have questions or want to discuss? Find me on the fediverse:
- Matrix: @mauvehed:takeonme.org
- Mastodon: @[email protected]
- Feddit: [email protected]
- Pixel: [email protected]