Making Forgejo the Source of Truth Without Rebuilding My Deployment Pipeline
How I made Forgejo canonical for five static websites while keeping GitHub and Cloudflare Pages as the existing deployment path.

7 min read
I have five static websites running on Cloudflare Pages:
- derekleeds.com
- resume.derekleeds.com
- learn.derekleeds.cloud
- guides.derekleeds.cloud
- leedswebservices.com
Cloudflare builds each site from a GitHub repository when a commit reaches main. That part works well. I did not want to replace it.
The problem was farther upstream. My homelab uses Forgejo as the source of truth for code and machine-readable configuration, but GitHub held the current versions of these sites. Four matching Forgejo repositories did not exist. The Guides repository existed in both places, but the Forgejo copy had fallen behind.
That left me with two possible places to make changes and no trustworthy rule about which one won. It was manageable while I was the only person touching the sites. It became a bad fit once I wanted Hermes, ChatGPT Work, and other agent profiles to work on them too.
One canonical forge, one deployment relay
I settled on a simple flow:
Derek or an authorized agent
→ Forgejo canonical repository
→ one-way push mirror
→ GitHub deployment repository
→ Cloudflare PagesForgejo owns the source. GitHub receives the Git refs and keeps doing the job it already does well: notifying Cloudflare Pages that a new build is ready.
This is not bidirectional synchronization. GitHub was imported into Forgejo once during the transition. After the push mirrors were accepted, changes began in Forgejo and moved in one direction.
That distinction matters. Two-way Git synchronization sounds convenient until both sides accept a commit to the same branch. Then somebody has to decide which history wins, whether a force push is safe, and how deleted refs should propagate. I do not need that machinery for five static sites. I need one writer and a dependable delivery path.
Pulling the current sites into Forgejo
Before changing repository authority, I froze writes to the GitHub repositories. The goal was to keep the source still long enough to capture it exactly.
I recorded the GitHub default branches, visibility, and current commit IDs. All five repositories use main. Four are private, while Guides is public. I then created the missing repositories under the homelab organization in Forgejo with matching visibility.
The Guides repository needed extra care because it already had history in Forgejo. I preserved its previous main under this recovery branch before updating it:
archive/pre-github-import-2026-08-20That branch is not part of the normal workflow. It is there in case I need to inspect or recover something that existed only in the old Forgejo state.
After importing branches and tags, I compared the main commit IDs between GitHub and Forgejo. All five matched. I also checked repository visibility and default branches through the Forgejo API.
There was one small wrinkle. When branches were pushed into a newly empty Forgejo repository, Forgejo temporarily selected the first imported branch as the default instead of main. Nothing was lost, but it was a good reminder that repository migration includes metadata as well as Git objects. I corrected the defaults and verified them again.
At the end of the import, Forgejo had current copies of all five repositories and the old Guides head remained recoverable.
Publishing back to GitHub with smaller credentials
Each Forgejo repository now has a one-way push mirror to its GitHub counterpart.
I use one GitHub fine-grained personal access token per repository. Each token can access only its selected repository and only needs Contents: Read and write; GitHub supplies Metadata: Read. It does not need organization-wide access, repository administration, GitHub Actions control, Pages administration, or webhook permissions.
That means a token for derekleeds/resume-derekleeds cannot push to derekleeds/learn, and neither token can change repository settings. A leaked credential would still be a problem, but the damage would be limited to one repository’s Git contents instead of all five sites.
The token values live in 1Password and Forgejo’s runtime mirror configuration. They are not copied into Git remotes, shell history, documentation, or chat transcripts.
I rolled the mirrors out one repository at a time, starting with the small resume redirect site. For every site, a Forgejo-originated validation commit appeared in GitHub, triggered a successful Cloudflare Pages production build, and left the public endpoint working.
This took longer than configuring five mirrors in one batch, but each failure would have had a small blast radius and an obvious rollback: disable the affected mirror and stop before touching the next repository.
Why I kept GitHub in the path
I considered moving the deployment itself away from GitHub. Forgejo could run CI, build the sites, and publish artifacts to Cloudflare through an API or command-line tool.
That would remove GitHub from the chain, but it would also replace a working native integration with custom automation that I would have to secure, monitor, and repair. The source-of-truth problem did not require a new deployment system.
Keeping GitHub as a deployment relay is a practical compromise. Forgejo owns the part I care about controlling. GitHub and Cloudflare keep handling the external build trigger they already handle reliably.
If that integration becomes a problem later, I can replace it as a separate project. There is no reason to smuggle a hosting migration into a repository-authority change.
Writing down the decision, not just the commands
This change affects more than where a remote points. It sets rules for every person and agent that edits these sites. That made it worth recording as an Architecture Decision Record.
I created ADR-0010 using the Markdown Architectural Decision Records, or MADR, format. The ADR records the context, decision drivers, options, outcome, and consequences. The main options were:
- Keep GitHub canonical and use Forgejo as a periodic backup.
- Allow both forges to accept writes and synchronize in both directions.
- Make Forgejo canonical and mirror outward to GitHub.
- Remove GitHub from the deployment path and build directly from Forgejo.
The ADR chooses the third option and explains why. It also records the costs: GitHub-side edits become unsafe after cutover, mirror credentials need rotation and monitoring, and production still depends on GitHub even though desired state does not.
The implementation plan answers how to perform the migration. The ADR answers why this architecture exists. That difference will matter months from now when somebody sees GitHub between Forgejo and Cloudflare and wonders why I did not remove it.
Without the ADR, the setup might look accidental. With it, a future agent can see that GitHub is retained deliberately as a deployment mirror, not treated as a second source of truth.
The rule after cutover
Normal changes now start in Forgejo. That applies to me, Hermes, ChatGPT Work, and any other authorized profile.
GitHub is deployment-only by convention. If an emergency change lands directly in GitHub, the response is not to let the two repositories drift and hope the next sync sorts it out. I need to pause the affected mirror, import the GitHub commit into Forgejo, verify parity, and then resume publishing.
The rule is intentionally boring:
Write in Forgejo. Deploy through GitHub. Build on Cloudflare Pages.Boring rules are easier for humans and agents to follow, and much easier to debug when something stops moving.
Where the migration stands
The migration is complete. All five Forgejo repositories are canonical, their GitHub main branches match, and the old Guides state remains preserved under its archive branch.
All five repository-scoped credentials passed both intended-repository push checks and unrelated-private-repository denial checks. Each push mirror completed successfully, each Cloudflare Pages deployment reported success, and each public site passed its runtime check.
I kept the existing deployment pipeline because it works. The improvement is that it now starts from a source of truth I control, with a clear path from an approved change to the public site.
